refactor css & tests

AuthorKonata <konata@posteo.jp>
Date
Commita1baf9a94c5a003bf448982053705a5d3c4d831a
Parent2161347
47 files changed, 7165 insertions(+), 6638 deletions(-)
Mbun.lock
@@ -8,14 +8,12 @@
88 "@elysiajs/static": "^1.4.7",
99 "@jsquash/jxl": "^1.3.0",
1010 "@kitajs/html": "^4.2.13",
11- "@simplewebauthn/browser": "^13.3.0",
1211 "@simplewebauthn/server": "^13.3.0",
1312 "argon2": "^0.44.0",
1413 "elysia": "^1.4.27",
1514 "file-type": "^21.3.2",
1615 "isomorphic-dompurify": "^3.3.0",
17- "jxl-rs-polyfill": "^0.1.1",
18- "kysely": "^0.28.12",
16+ "kysely": "^0.28.14",
1917 "kysely-bun-sqlite": "^0.4.0",
2018 "linguist-languages": "^9.3.1",
2119 "marked": "^17.0.4",
@@ -25,9 +23,11 @@
2523 },
2624 "devDependencies": {
2725 "@biomejs/biome": "^2.4.7",
26+ "@simplewebauthn/browser": "^13.3.0",
2827 "@types/argon2": "^0.15.4",
2928 "@types/bun": "latest",
3029 "@types/ssh2": "^1.15.5",
30+ "jxl-rs-polyfill": "^0.1.1",
3131 "lightningcss": "^1.32.0",
3232 "playwright": "^1.58.2",
3333 },
@@ -303,7 +303,7 @@
303303
304304 "jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="],
305305
306- "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
306+ "kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="],
307307
308308 "kysely-bun-sqlite": ["kysely-bun-sqlite@0.4.0", "", { "dependencies": { "bun-types": "^1.1.31" }, "peerDependencies": { "kysely": "^0.28.2" } }, "sha512-2EkQE5sT4ewiw7IWfJsAkpxJ/QPVKXKO5sRYI/xjjJIJlECuOdtG+ssYM0twZJySrdrmuildNPFYVreyu1EdZg=="],
309309
Mpackage.json
@@ -7,7 +7,7 @@
77 "css:build": "bun scripts/build-css.ts",
88 "db:init": "bun run src/db/init.ts",
99 "db:seed": "bun run src/db/seed.ts",
10- "test": "bun test --timeout 60000",
10+ "test": "bun scripts/test.ts",
1111 "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
1212 "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
1313 "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build",
@@ -38,7 +38,7 @@
3838 "elysia": "^1.4.27",
3939 "file-type": "^21.3.2",
4040 "isomorphic-dompurify": "^3.3.0",
41- "kysely": "^0.28.12",
41+ "kysely": "^0.28.14",
4242 "kysely-bun-sqlite": "^0.4.0",
4343 "linguist-languages": "^9.3.1",
4444 "marked": "^17.0.4",
Mscripts/build-css.ts
@@ -1,14 +1,12 @@
11 import { watch } from "fs";
2-import { transform } from "lightningcss";
2+import { bundle } from "lightningcss";
33
44 const INPUT = "src/styles/main.css";
55 const OUTPUT = "public/assets/main.css";
66
77 async function build() {
8- const source = Bun.file(INPUT);
9- const { code } = transform({
8+ const { code } = bundle({
109 filename: INPUT,
11- code: await source.bytes(),
1210 minify: true,
1311 sourceMap: false,
1412 });
@@ -19,12 +17,12 @@ async function build() {
1917 await build();
2018
2119 if (process.argv.includes("--watch")) {
22- watch(INPUT, async () => {
20+ watch("src/styles", { recursive: true }, async () => {
2321 try {
2422 await build();
2523 } catch (e) {
2624 console.error("[css]", e);
2725 }
2826 });
29- console.log(`[css] watching ${INPUT}`);
27+ console.log(`[css] watching src/styles`);
3028 }
Mscripts/dev.ts
@@ -1,9 +1,41 @@
1-const server = Bun.spawn(["bun", "--watch", "src/index.tsx"], { stdio: ["inherit", "inherit", "inherit"] });
2-const css = Bun.spawn(["bun", "scripts/build-css.ts", "--watch"], { stdio: ["inherit", "inherit", "inherit"] });
1+import { watch } from 'fs';
32
4-process.on("SIGINT", () => {
3+const SRC_DIR = 'src';
4+
5+async function spawnServer() {
6+ return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
7+ stdio: ['inherit', 'inherit', 'inherit'],
8+ });
9+}
10+
11+async function restart(current: ReturnType<typeof Bun.spawn>) {
12+ current.kill();
13+ await current.exited;
14+ return spawnServer();
15+}
16+
17+let server = await spawnServer();
18+
19+// Debounce rapid file-save bursts (e.g. formatter touching many files at once)
20+let debounce: ReturnType<typeof setTimeout> | null = null;
21+watch(SRC_DIR, { recursive: true }, (_, filename) => {
22+ if (filename?.endsWith('.css')) return; // CSS is handled by build-css.ts --watch
23+ if (debounce) clearTimeout(debounce);
24+ debounce = setTimeout(async () => {
25+ console.clear();
26+ console.log(`[dev] ${filename} changed — restarting…`);
27+ server = await restart(server);
28+ }, 50);
29+});
30+
31+const css = Bun.spawn(['bun', 'scripts/build-css.ts', '--watch'], {
32+ stdio: ['inherit', 'inherit', 'inherit'],
33+});
34+
35+process.on('SIGINT', async () => {
536 server.kill();
637 css.kill();
38+ await Promise.all([server.exited, css.exited]);
739 process.exit(0);
840 });
941
Ascripts/test.ts
@@ -0,0 +1,26 @@
1+import { readdirSync } from 'fs';
2+import { execFileSync } from 'child_process';
3+import path from 'path';
4+
5+const testsDir = path.resolve('tests');
6+const files = readdirSync(testsDir)
7+ .filter(f => f.endsWith('.test.ts'))
8+ .sort();
9+
10+let passed = 0;
11+let failed = 0;
12+
13+for (const file of files) {
14+ const filePath = path.join(testsDir, file);
15+ try {
16+ execFileSync('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
17+ stdio: 'inherit',
18+ });
19+ passed++;
20+ } catch {
21+ failed++;
22+ }
23+}
24+
25+console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
26+if (failed > 0) process.exit(1);
Asrc/app.ts
@@ -0,0 +1,35 @@
1+import path from "node:path";
2+import { staticPlugin } from "@elysiajs/static";
3+import { Elysia } from "elysia";
4+import config from "./config.ts";
5+import { authRoutes } from "./routes/auth.tsx";
6+import { avatarRoutes } from "./routes/avatars.ts";
7+import { gitRoutes } from "./routes/git.ts";
8+import { issueRoutes } from "./routes/issues.tsx";
9+import { patchRoutes } from "./routes/patches.tsx";
10+import { releasesRoutes } from "./routes/releases.tsx";
11+import { repoRoutes } from "./routes/repos.tsx";
12+import { settingsRoutes } from "./routes/settings.tsx";
13+import { syncStartup } from "./services/repoSync.ts";
14+
15+export async function createApp(port: number) {
16+ await syncStartup();
17+ return new Elysia({
18+ serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
19+ })
20+ .use(
21+ staticPlugin({
22+ assets: path.resolve("./public"),
23+ prefix: "/",
24+ }),
25+ )
26+ .use(gitRoutes)
27+ .use(settingsRoutes)
28+ .use(authRoutes)
29+ .use(repoRoutes)
30+ .use(issueRoutes)
31+ .use(patchRoutes)
32+ .use(releasesRoutes)
33+ .use(avatarRoutes)
34+ .listen(port);
35+}
Msrc/config.ts
@@ -1,30 +1,37 @@
11 import path from "node:path";
22
3-//env configs
4-export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin";
5-export const INLINE_MAX_BYTES =
6- parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288;
7-export const MAX_UPLOAD_BYTES =
8- parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024;
9-export const MAX_USER_UPLOAD_BYTES =
10- parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024;
11-export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY;
12-export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
13-export const SSH_DISABLED = !!process.env.SSH_DISABLED;
14-export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
15-export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
16-export const REGISTRATION_TYPE = (process.env.REGISTRATION_TYPE ?? "enabled") as
17- | "enabled"
18- | "disabled"
19- | "queue";
20-export const REGISTER_QUESTION = process.env.REGISTER_QUESTION ?? "";
21-export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
22-export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
23-export const HIGHLIGHT_WORKERS =
24- parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
25-export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
26-export const COMMITTER_EMAIL =
27- process.env.COMMITTER_EMAIL ??
28- `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
29-export const EXTRA_ALLOWED_SIGNERS_PATH =
30- process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null;
3+const env = process.env;
4+
5+const config = {
6+ OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin",
7+ INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288,
8+ MAX_UPLOAD_BYTES:
9+ parseInt(env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024,
10+ MAX_USER_UPLOAD_BYTES:
11+ parseInt(env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024,
12+ TRUSTED_PROXY: !!env.TRUSTED_PROXY,
13+ RATE_LIMIT_DISABLED: !!env.RATE_LIMIT_DISABLED,
14+ SSH_DISABLED: !!env.SSH_DISABLED,
15+ PORT: parseInt(env.PORT ?? "", 10) || 3000,
16+ SSH_PORT: parseInt(env.SSH_PORT ?? "", 10) || 2222,
17+ REGISTRATION_TYPE: (env.REGISTRATION_TYPE ?? "enabled") as
18+ | "enabled"
19+ | "disabled"
20+ | "queue",
21+ REGISTER_QUESTION: env.REGISTER_QUESTION ?? "",
22+ BASE_URL:
23+ env.BASE_URL ??
24+ `http://localhost:${parseInt(env.PORT ?? "", 10) || 3000}`,
25+ DATA_DIR: path.resolve(env.DATA_DIR ?? "./data"),
26+ HIGHLIGHT_WORKERS: parseInt(env.HIGHLIGHT_WORKERS ?? "", 10) || 4,
27+ COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin",
28+ COMMITTER_EMAIL: "",
29+ EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null,
30+};
31+
32+// Derived values that depend on other config fields
33+config.COMMITTER_EMAIL =
34+ env.COMMITTER_EMAIL ??
35+ `${config.OWNER_DISPLAY_NAME}@${new URL(config.BASE_URL).hostname}`;
36+
37+export default config;
Msrc/constants.ts
@@ -1,8 +1,8 @@
11 import path from "node:path";
2-import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts";
2+import config from "./config.ts";
33
44 // Auth / identity
5-export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`;
5+export const WEBAUTHN_RP_NAME = `${config.OWNER_DISPLAY_NAME}'s Hearthforge`;
66 export const ADMIN_USERNAME = "admin";
77 export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/;
88 export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/;
@@ -39,11 +39,27 @@ export const MAX_FILE_CACHE = 500;
3939 export const MAX_DIFF_CACHE = 500;
4040 export const MAX_PATCH_CACHE = 100;
4141
42-// Paths
43-export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
44-export const REPOS_DIR = path.join(DATA_DIR, "repos");
45-export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
46-export const RELEASES_DIR = path.join(DATA_DIR, "releases");
47-export const SSH_HOST_KEY_PATH =
48- process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key");
49-export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers");
42+// Paths — derived from config.DATA_DIR via getters so they reflect overrides.
43+export const paths = {
44+ get DB_PATH() {
45+ return path.join(config.DATA_DIR, "hearthforge.db");
46+ },
47+ get REPOS_DIR() {
48+ return path.join(config.DATA_DIR, "repos");
49+ },
50+ get AVATARS_DIR() {
51+ return path.join(config.DATA_DIR, "avatars");
52+ },
53+ get RELEASES_DIR() {
54+ return path.join(config.DATA_DIR, "releases");
55+ },
56+ get SSH_HOST_KEY_PATH() {
57+ return (
58+ process.env.SSH_HOST_KEY_PATH ??
59+ path.join(config.DATA_DIR, "ssh_host_key")
60+ );
61+ },
62+ get ALLOWED_SIGNERS_PATH() {
63+ return path.join(config.DATA_DIR, "allowed_signers");
64+ },
65+};
Msrc/db/index.ts
@@ -2,7 +2,7 @@ import { Database as BunDatabase } from "bun:sqlite";
22 import { type Generated, Kysely, type Selectable } from "kysely";
33 import { BunSqliteDialect } from "kysely-bun-sqlite";
44
5-import { DB_PATH } from "../constants.ts";
5+import { paths } from "../constants.ts";
66
77 interface UserTable {
88 id: Generated<number>;
@@ -190,7 +190,7 @@ export type ReleaseRow = Selectable<ReleaseTable>;
190190 export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
191191 export type LabelRow = Selectable<LabelTable>;
192192
193-const sqlite = new BunDatabase(DB_PATH);
193+let sqlite = new BunDatabase(paths.DB_PATH);
194194 sqlite.run("PRAGMA journal_mode=WAL");
195195 sqlite.run("PRAGMA foreign_keys=ON");
196196
@@ -220,10 +220,23 @@ sqlite.run(
220220 "UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
221221 );
222222
223-export const db = new Kysely<Database>({
223+export let db = new Kysely<Database>({
224224 dialect: new BunSqliteDialect({ database: sqlite }),
225225 });
226226
227+/** Close the current DB and reopen from disk (used by tests after data wipe). */
228+export function resetDb() {
229+ try {
230+ sqlite.close();
231+ } catch {}
232+ sqlite = new BunDatabase(paths.DB_PATH);
233+ sqlite.run("PRAGMA journal_mode=WAL");
234+ sqlite.run("PRAGMA foreign_keys=ON");
235+ db = new Kysely<Database>({
236+ dialect: new BunSqliteDialect({ database: sqlite }),
237+ });
238+}
239+
227240 // Migration: add allow_user_labels column to repositories if missing
228241 const repoCols = sqlite
229242 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
Msrc/db/init.ts
@@ -2,20 +2,15 @@ import { Database } from "bun:sqlite";
22 import { mkdirSync, readFileSync } from "node:fs";
33 import path from "node:path";
44 import * as argon2 from "argon2";
5-import { DATA_DIR } from "../config.ts";
6-import {
7- ADMIN_USERNAME,
8- AVATARS_DIR,
9- DB_PATH,
10- REPOS_DIR,
11-} from "../constants.ts";
5+import config from "../config.ts";
6+import { ADMIN_USERNAME, paths } from "../constants.ts";
127
138 // Ensure data directories exist
14-mkdirSync(DATA_DIR, { recursive: true });
15-mkdirSync(REPOS_DIR, { recursive: true });
16-mkdirSync(AVATARS_DIR, { recursive: true });
9+mkdirSync(config.DATA_DIR, { recursive: true });
10+mkdirSync(paths.REPOS_DIR, { recursive: true });
11+mkdirSync(paths.AVATARS_DIR, { recursive: true });
1712
18-const db = new Database(DB_PATH);
13+const db = new Database(paths.DB_PATH);
1914 db.run("PRAGMA journal_mode=WAL");
2015 db.run("PRAGMA foreign_keys=ON");
2116
@@ -47,5 +42,5 @@ if (!existing) {
4742 console.log("Admin account already exists.");
4843 }
4944
50-console.log("Database initialized at", DB_PATH);
45+console.log("Database initialized at", paths.DB_PATH);
5146 db.close();
Msrc/index.tsx
@@ -1,16 +1,5 @@
1-import path from "node:path";
2-import { staticPlugin } from "@elysiajs/static";
3-import { Elysia } from "elysia";
4-import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts";
5-import { authRoutes } from "./routes/auth.tsx";
6-import { avatarRoutes } from "./routes/avatars.ts";
7-import { gitRoutes } from "./routes/git.ts";
8-import { issueRoutes } from "./routes/issues.tsx";
9-import { patchRoutes } from "./routes/patches.tsx";
10-import { releasesRoutes } from "./routes/releases.tsx";
11-import { repoRoutes } from "./routes/repos.tsx";
12-import { settingsRoutes } from "./routes/settings.tsx";
13-import { syncStartup } from "./services/repoSync.ts";
1+import { createApp } from "./app.ts";
2+import config from "./config.ts";
143 import { startSshServer } from "./services/sshServer.ts";
154
165 for (const cmd of ["git", "ssh-keygen"]) {
@@ -21,22 +10,6 @@ for (const cmd of ["git", "ssh-keygen"]) {
2110 }
2211 }
2312
24-await syncStartup();
25-if (!SSH_DISABLED) await startSshServer();
26-new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } })
27- .use(
28- staticPlugin({
29- assets: path.resolve("./public"),
30- prefix: "/",
31- }),
32- )
33- .use(gitRoutes)
34- .use(settingsRoutes)
35- .use(authRoutes)
36- .use(repoRoutes)
37- .use(issueRoutes)
38- .use(patchRoutes)
39- .use(releasesRoutes)
40- .use(avatarRoutes)
41- .listen(PORT);
42-console.log(`Hearthforge running at http://localhost:${PORT}`);
13+if (!config.SSH_DISABLED) await startSshServer();
14+await createApp(config.PORT);
15+console.log(`Hearthforge running at http://localhost:${config.PORT}`);
Msrc/lib/rateLimiter.ts
@@ -1,4 +1,4 @@
1-import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts";
1+import config from "../config.ts";
22
33 interface Bucket {
44 count: number;
@@ -12,7 +12,7 @@ export function checkRateLimit(
1212 maxRequests: number,
1313 windowMs: number,
1414 ): boolean {
15- if (RATE_LIMIT_DISABLED || !ip) return true;
15+ if (config.RATE_LIMIT_DISABLED || !ip) return true;
1616 const now = Date.now();
1717 const bucket = buckets.get(ip);
1818 if (!bucket || now > bucket.resetAt) {
@@ -28,7 +28,7 @@ export function getClientIp(
2828 request: Request,
2929 server: Bun.Server<unknown> | null,
3030 ): string | null {
31- if (TRUSTED_PROXY) {
31+ if (config.TRUSTED_PROXY) {
3232 return (
3333 request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
3434 null
Msrc/lib/users.ts
@@ -1,8 +1,8 @@
1-import { OWNER_DISPLAY_NAME } from "../config.ts";
1+import config from "../config.ts";
22 import { ADMIN_USERNAME } from "../constants.ts";
33
44 /** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */
55 export function displayName(username: string | null | undefined): string {
66 if (!username) return "[Deleted user]";
7- return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username;
7+ return username === ADMIN_USERNAME ? config.OWNER_DISPLAY_NAME : username;
88 }
Msrc/routes/auth.tsx
@@ -6,7 +6,7 @@ import {
66 } from "@simplewebauthn/server";
77 import * as argon2 from "argon2";
88 import { Elysia, t } from "elysia";
9-import { REGISTER_QUESTION, REGISTRATION_TYPE } from "../config.ts";
9+import config from "../config.ts";
1010 import {
1111 ADMIN_USERNAME,
1212 CHALLENGE_TTL_MS,
@@ -130,15 +130,15 @@ export const authRoutes = new Elysia()
130130 )
131131
132132 .get("/register", () => {
133- if (REGISTRATION_TYPE === "disabled")
133+ if (config.REGISTRATION_TYPE === "disabled")
134134 return new Response("Registration is disabled", { status: 403 });
135- return html(<Register question={REGISTER_QUESTION} />);
135+ return html(<Register question={config.REGISTER_QUESTION} />);
136136 })
137137
138138 .post(
139139 "/register",
140140 async ({ body, request, server }) => {
141- if (REGISTRATION_TYPE === "disabled")
141+ if (config.REGISTRATION_TYPE === "disabled")
142142 return new Response("Registration is disabled", {
143143 status: 403,
144144 });
@@ -147,7 +147,7 @@ export const authRoutes = new Elysia()
147147 return html(
148148 <Register
149149 error="Too many registration attempts. Please try again later."
150- question={REGISTER_QUESTION}
150+ question={config.REGISTER_QUESTION}
151151 />,
152152 );
153153 }
@@ -157,7 +157,7 @@ export const authRoutes = new Elysia()
157157 return html(
158158 <Register
159159 error="Username may only contain letters, numbers, hyphens, and underscores"
160- question={REGISTER_QUESTION}
160+ question={config.REGISTER_QUESTION}
161161 />,
162162 );
163163 }
@@ -165,7 +165,7 @@ export const authRoutes = new Elysia()
165165 return html(
166166 <Register
167167 error="That username is reserved"
168- question={REGISTER_QUESTION}
168+ question={config.REGISTER_QUESTION}
169169 />,
170170 );
171171 }
@@ -174,7 +174,7 @@ export const authRoutes = new Elysia()
174174 return html(
175175 <Register
176176 error="Password is required (use the passkey button for passwordless registration)"
177- question={REGISTER_QUESTION}
177+ question={config.REGISTER_QUESTION}
178178 />,
179179 );
180180 }
@@ -182,7 +182,7 @@ export const authRoutes = new Elysia()
182182 return html(
183183 <Register
184184 error="Passwords do not match"
185- question={REGISTER_QUESTION}
185+ question={config.REGISTER_QUESTION}
186186 />,
187187 );
188188 }
@@ -190,14 +190,14 @@ export const authRoutes = new Elysia()
190190 return html(
191191 <Register
192192 error="Password must be at least 8 characters"
193- question={REGISTER_QUESTION}
193+ question={config.REGISTER_QUESTION}
194194 />,
195195 );
196196 }
197197
198198 const hash = await argon2.hash(password);
199199 const now = new Date().toISOString();
200- const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
200+ const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
201201 let result: { id: number };
202202 try {
203203 result = await db
@@ -221,16 +221,19 @@ export const authRoutes = new Elysia()
221221 return html(
222222 <Register
223223 error="Username already taken"
224- question={REGISTER_QUESTION}
224+ question={config.REGISTER_QUESTION}
225225 />,
226226 );
227227 }
228228 throw err;
229229 }
230230
231- if (REGISTRATION_TYPE === "queue") {
231+ if (config.REGISTRATION_TYPE === "queue") {
232232 return html(
233- <Register pending={true} question={REGISTER_QUESTION} />,
233+ <Register
234+ pending={true}
235+ question={config.REGISTER_QUESTION}
236+ />,
234237 );
235238 }
236239
@@ -262,7 +265,7 @@ export const authRoutes = new Elysia()
262265 .post(
263266 "/auth/passkey/create-user",
264267 async ({ body }) => {
265- if (REGISTRATION_TYPE === "disabled") {
268+ if (config.REGISTRATION_TYPE === "disabled") {
266269 return new Response(
267270 JSON.stringify({ error: "Registration is disabled" }),
268271 { status: 400 },
@@ -284,7 +287,7 @@ export const authRoutes = new Elysia()
284287 );
285288 }
286289 const now = new Date().toISOString();
287- const isPending = REGISTRATION_TYPE === "queue" ? 1 : 0;
290+ const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
288291 let result: { id: number };
289292 try {
290293 result = await db
@@ -313,7 +316,7 @@ export const authRoutes = new Elysia()
313316 throw err;
314317 }
315318
316- if (REGISTRATION_TYPE === "queue") {
319+ if (config.REGISTRATION_TYPE === "queue") {
317320 return new Response(
318321 JSON.stringify({ ok: true, pending: true }),
319322 { headers: { "Content-Type": "application/json" } },
Msrc/routes/avatars.ts
@@ -1,5 +1,5 @@
11 import { Elysia, t } from "elysia";
2-import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
2+import config from "../config.ts";
33 import { db } from "../db/index.ts";
44 import { requireAuth, resolveSession } from "../middleware/session.ts";
55 import {
@@ -55,7 +55,7 @@ export const avatarRoutes = new Elysia()
5555 const user = await resolveSession(cookie.session.value);
5656 const deny = requireAuth(user);
5757 if (deny) return deny;
58- if (body.avatar.size > MAX_USER_UPLOAD_BYTES) {
58+ if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) {
5959 return new Response("Avatar file too large", { status: 400 });
6060 }
6161 const buffer = Buffer.from(await body.avatar.arrayBuffer());
Msrc/routes/git.ts
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
22 import path from "node:path";
33 import * as argon2 from "argon2";
44 import { Elysia, t } from "elysia";
5-import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
5+import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
66 import { db } from "../db";
77
88 function pktLine(str: string): Buffer {
@@ -59,7 +59,7 @@ async function getRepo(
5959 .where("name", "=", repoName)
6060 .executeTakeFirst();
6161 if (!repo) return null;
62- const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
62+ const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
6363 if (!existsSync(repoPath)) return null;
6464 return { repoPath, isPrivate: repo.is_private === 1 };
6565 }
Msrc/routes/patches.tsx
@@ -1,10 +1,6 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
3-import {
4- COMMITTER_EMAIL,
5- COMMITTER_NAME,
6- MAX_USER_UPLOAD_BYTES,
7-} from "../config.ts";
3+import config from "../config.ts";
84 import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
95 import { db, getRepo, type LabelRow } from "../db/index.ts";
106 import {
@@ -293,7 +289,7 @@ export const patchRoutes = new Elysia()
293289 );
294290 }
295291
296- if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
292+ if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
297293 return html(
298294 <NewPatch
299295 user={user!}
@@ -637,8 +633,8 @@ export const patchRoutes = new Elysia()
637633 patch.patch_content,
638634 mergeMeta.author,
639635 mergeMeta.email,
640- COMMITTER_NAME,
641- COMMITTER_EMAIL,
636+ config.COMMITTER_NAME,
637+ config.COMMITTER_EMAIL,
642638 );
643639 } catch (err) {
644640 // Roll back the status if the git operation fails
@@ -689,7 +685,7 @@ export const patchRoutes = new Elysia()
689685 if (!body.patch_file || body.patch_file.size === 0) {
690686 return new Response("Patch file is required", { status: 400 });
691687 }
692- if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
688+ if (body.patch_file.size > config.MAX_USER_UPLOAD_BYTES) {
693689 return new Response("Patch file is too large", { status: 400 });
694690 }
695691
Msrc/routes/releases.tsx
@@ -1,8 +1,8 @@
11 import { mkdirSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
4-import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
5-import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
4+import config from "../config.ts";
5+import { paths, RELEASES_PER_PAGE } from "../constants.ts";
66 import { db, getRepo } from "../db/index.ts";
77 import { requireAdmin, resolveSession } from "../middleware/session.ts";
88 import { archiveRepo, git } from "../services/git.ts";
@@ -184,8 +184,8 @@ export const releasesRoutes = new Elysia()
184184 tagName,
185185 revision,
186186 tagMessage,
187- COMMITTER_NAME,
188- COMMITTER_EMAIL,
187+ config.COMMITTER_NAME,
188+ config.COMMITTER_EMAIL,
189189 );
190190 if (tagResult === "already_exists") {
191191 return html(
@@ -268,7 +268,7 @@ export const releasesRoutes = new Elysia()
268268 .executeTakeFirstOrThrow();
269269
270270 const id = inserted.id;
271- releaseDir = path.join(RELEASES_DIR, String(id));
271+ releaseDir = path.join(paths.RELEASES_DIR, String(id));
272272
273273 if (includeSource) {
274274 const sourceDir = path.join(releaseDir, "source");
@@ -320,7 +320,7 @@ export const releasesRoutes = new Elysia()
320320 // is no risk of contaminating a later release.
321321 if (includeSource) {
322322 const sourceDir = path.join(
323- RELEASES_DIR,
323+ paths.RELEASES_DIR,
324324 String(releaseId),
325325 "source",
326326 );
@@ -407,7 +407,7 @@ export const releasesRoutes = new Elysia()
407407 if (release.include_source_code) {
408408 const base = `${repo.name}-${release.tag_name}`;
409409 const sourceDir = path.join(
410- RELEASES_DIR,
410+ paths.RELEASES_DIR,
411411 String(release.id),
412412 "source",
413413 );
@@ -477,7 +477,10 @@ export const releasesRoutes = new Elysia()
477477 // Remove files from disk before the DB record so that a crash
478478 // between the two leaves a broken-but-visible repo rather than a
479479 // DB record pointing to missing files.
480- const releaseDir = path.join(RELEASES_DIR, String(release.id));
480+ const releaseDir = path.join(
481+ paths.RELEASES_DIR,
482+ String(release.id),
483+ );
481484 rmSync(releaseDir, { recursive: true, force: true });
482485 await db
483486 .deleteFrom("releases")
@@ -522,7 +525,7 @@ export const releasesRoutes = new Elysia()
522525 if (!asset) return new Response("Not found", { status: 404 });
523526
524527 const filePath = path.join(
525- RELEASES_DIR,
528+ paths.RELEASES_DIR,
526529 String(release.id),
527530 "assets",
528531 safeFilename,
@@ -565,7 +568,7 @@ export const releasesRoutes = new Elysia()
565568
566569 const safeFilename = path.basename(params.filename);
567570 const filePath = path.join(
568- RELEASES_DIR,
571+ paths.RELEASES_DIR,
569572 String(release.id),
570573 "source",
571574 safeFilename,
Msrc/routes/repos.tsx
@@ -2,10 +2,10 @@ import { readFileSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { fileTypeFromBuffer } from "file-type";
5-import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
5+import config from "../config.ts";
66 import {
7- ALLOWED_SIGNERS_PATH,
87 COMMITS_PER_PAGE,
8+ paths,
99 REPOS_PER_PAGE,
1010 VALID_REPO_NAME_RE,
1111 } from "../constants.ts";
@@ -78,7 +78,7 @@ async function readReadme(
7878
7979 export const repoRoutes = new Elysia()
8080 .get("/allowed_signers", () => {
81- return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
81+ return new Response(readFileSync(paths.ALLOWED_SIGNERS_PATH), {
8282 headers: { "Content-Type": "text/plain; charset=utf-8" },
8383 });
8484 })
@@ -593,8 +593,8 @@ export const repoRoutes = new Elysia()
593593 filePath,
594594 content,
595595 message,
596- COMMITTER_NAME,
597- COMMITTER_EMAIL,
596+ config.COMMITTER_NAME,
597+ config.COMMITTER_EMAIL,
598598 );
599599
600600 return new Response(null, {
Msrc/routes/settings.tsx
@@ -1,7 +1,7 @@
11 import * as argon2 from "argon2";
22 import { Elysia, t } from "elysia";
33 import { utils as sshUtils } from "ssh2";
4-import { REGISTRATION_TYPE } from "../config.ts";
4+import config from "../config.ts";
55 import {
66 ADMIN_USERNAME,
77 VALID_KEY_TYPES,
@@ -55,7 +55,7 @@ export const settingsRoutes = new Elysia()
5555 const hasPassword = !!userRow?.password_hash;
5656
5757 const pendingUsers =
58- user.isAdmin && REGISTRATION_TYPE === "queue"
58+ user.isAdmin && config.REGISTRATION_TYPE === "queue"
5959 ? await db
6060 .selectFrom("users")
6161 .select([
Msrc/services/avatar.ts
@@ -3,9 +3,9 @@ import path from "node:path";
33 import { encode } from "@jsquash/jxl";
44 import { fileTypeFromBuffer } from "file-type";
55 import sharp from "sharp";
6-import { AVATARS_DIR } from "../constants.ts";
6+import { paths } from "../constants.ts";
77
8-mkdirSync(AVATARS_DIR, { recursive: true });
8+mkdirSync(paths.AVATARS_DIR, { recursive: true });
99
1010 // FNV-1a: produces n deterministic bytes from a string
1111 function hashBytes(s: string, n: number): number[] {
@@ -41,7 +41,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
4141 }
4242
4343 export function avatarJxlPath(userId: number): string {
44- return path.join(AVATARS_DIR, `${userId}.jxl`);
44+ return path.join(paths.AVATARS_DIR, `${userId}.jxl`);
4545 }
4646
4747 export async function processAndStoreAvatar(
Msrc/services/diffHighlight.ts
@@ -1,6 +1,6 @@
11 import path from "node:path";
22 import type { BundledLanguage } from "shiki";
3-import { INLINE_MAX_BYTES } from "../config.ts";
3+import config from "../config.ts";
44 import { git } from "./git.ts";
55 import { detectLang, getHighlighter } from "./highlight.ts";
66
@@ -271,7 +271,7 @@ export async function highlightFile(
271271 0,
272272 );
273273 const highlightedHunks =
274- totalBytes > INLINE_MAX_BYTES
274+ totalBytes > config.INLINE_MAX_BYTES
275275 ? file.hunks.map((hunk) =>
276276 hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
277277 )
Msrc/services/git.ts
@@ -1,11 +1,7 @@
11 import path from "node:path";
22 import { $ as _$ } from "bun";
33
4-import {
5- ALLOWED_SIGNERS_PATH,
6- REPOS_DIR,
7- SSH_HOST_KEY_PATH,
8-} from "../constants.ts";
4+import { paths } from "../constants.ts";
95
106 const gitEnv = {
117 ...process.env,
@@ -45,7 +41,7 @@ async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
4541 }
4642
4743 export function repoPath(name: string): string {
48- return path.join(REPOS_DIR, `${name}.git`);
44+ return path.join(paths.REPOS_DIR, `${name}.git`);
4945 }
5046
5147 export async function validateCommit(
@@ -268,7 +264,7 @@ export const git = {
268264 "-c",
269265 "gpg.format=ssh",
270266 "-c",
271- `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
267+ `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
272268 ];
273269 try {
274270 const out =
@@ -465,7 +461,7 @@ export const git = {
465461 "-c",
466462 "gpg.format=ssh",
467463 "-c",
468- `user.signingKey=${SSH_HOST_KEY_PATH}`,
464+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
469465 ];
470466 const commit = (
471467 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
@@ -515,7 +511,7 @@ export const git = {
515511 "-c",
516512 "gpg.format=ssh",
517513 "-c",
518- `user.signingKey=${SSH_HOST_KEY_PATH}`,
514+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
519515 ];
520516 const commit = (
521517 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
@@ -550,7 +546,7 @@ export const git = {
550546 "-c",
551547 "gpg.format=ssh",
552548 "-c",
553- `user.signingKey=${SSH_HOST_KEY_PATH}`,
549+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
554550 ];
555551 const result =
556552 message !== undefined
@@ -606,7 +602,7 @@ export const git = {
606602 "-c",
607603 "gpg.format=ssh",
608604 "-c",
609- `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
605+ `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
610606 ];
611607 try {
612608 const [metaOut, msgOut] = await Promise.all([
Msrc/services/highlight.ts
@@ -7,7 +7,7 @@ import {
77 createHighlighter,
88 type Highlighter,
99 } from "shiki";
10-import { INLINE_MAX_BYTES } from "../config.ts";
10+import config from "../config.ts";
1111 import { MAX_FILE_CACHE } from "../constants.ts";
1212
1313 let highlighter: Highlighter | null = null;
@@ -170,7 +170,7 @@ export async function serveFile(
170170 if (isBinary) {
171171 return { type: "binary", size: content.length };
172172 }
173- if (content.length > INLINE_MAX_BYTES) {
173+ if (content.length > config.INLINE_MAX_BYTES) {
174174 return { type: "download", size: content.length };
175175 }
176176 const text = content.toString("utf-8");
Msrc/services/highlightWorker.ts
@@ -1,4 +1,4 @@
1-import { HIGHLIGHT_WORKERS } from "../config.ts";
1+import config from "../config.ts";
22 import { MAX_DIFF_CACHE } from "../constants.ts";
33 import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts";
44 import { parseDiff } from "./diffHighlight.ts";
@@ -59,7 +59,7 @@ function onMessage(handle: WorkerHandle, event: MessageEvent) {
5959 }
6060
6161 const pool: WorkerHandle[] = Array.from(
62- { length: HIGHLIGHT_WORKERS },
62+ { length: config.HIGHLIGHT_WORKERS },
6363 createHandle,
6464 );
6565
Msrc/services/repoSync.ts
@@ -8,14 +8,8 @@ import {
88 } from "node:fs";
99 import path from "node:path";
1010 import { $ } from "bun";
11-import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts";
12-import {
13- ALLOWED_SIGNERS_PATH,
14- RELEASES_DIR,
15- REPOS_DIR,
16- SSH_HOST_KEY_PATH,
17- VALID_REPO_NAME_RE,
18-} from "../constants.ts";
11+import config from "../config.ts";
12+import { paths, VALID_REPO_NAME_RE } from "../constants.ts";
1913 import type { RepositoryRow } from "../db/index.ts";
2014 import { db } from "../db/index.ts";
2115 import { git, repoPath } from "../services/git.ts";
@@ -30,12 +24,15 @@ async function convertNonBareRepo(
3024 const dotGitPath = path.join(entryPath, ".git");
3125 const hasGitSuffix = entryName.endsWith(".git");
3226 const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
33- const targetPath = path.join(REPOS_DIR, baseName);
27+ const targetPath = path.join(paths.REPOS_DIR, baseName);
3428
3529 try {
3630 if (hasGitSuffix) {
3731 // Case 2: source and target path are the same dir — use a temp location.
38- const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`);
32+ const tmpPath = path.join(
33+ paths.REPOS_DIR,
34+ `.${entryName}.bare_tmp`,
35+ );
3936 renameSync(dotGitPath, tmpPath);
4037 rmSync(entryPath, { recursive: true, force: true });
4138 renameSync(tmpPath, targetPath);
@@ -56,11 +53,11 @@ async function convertNonBareRepo(
5653 }
5754 }
5855
59-// Scans REPOS_DIR for non-bare repos and converts them before the main sync.
56+// Scans paths.REPOS_DIR for non-bare repos and converts them before the main sync.
6057 async function convertNonBareRepos(): Promise<void> {
6158 let entries: Dirent[];
6259 try {
63- entries = readdirSync(REPOS_DIR, { withFileTypes: true });
60+ entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true });
6461 } catch {
6562 return;
6663 }
@@ -68,7 +65,7 @@ async function convertNonBareRepos(): Promise<void> {
6865 const conversions: Promise<void>[] = [];
6966 for (const entry of entries) {
7067 if (!entry.isDirectory()) continue;
71- const entryPath = path.join(REPOS_DIR, entry.name);
68+ const entryPath = path.join(paths.REPOS_DIR, entry.name);
7269 if (existsSync(path.join(entryPath, ".git"))) {
7370 conversions.push(convertNonBareRepo(entry.name, entryPath));
7471 }
@@ -78,7 +75,7 @@ async function convertNonBareRepos(): Promise<void> {
7875
7976 export function listDiskRepoNames(): string[] {
8077 try {
81- return readdirSync(REPOS_DIR, { withFileTypes: true })
78+ return readdirSync(paths.REPOS_DIR, { withFileTypes: true })
8279 .filter((e) => e.isDirectory() && e.name.endsWith(".git"))
8380 .map((e) => e.name.slice(0, -4));
8481 } catch {
@@ -115,22 +112,26 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
115112 }
116113
117114 async function ensureSigningSetup(): Promise<void> {
118- const hostname = new URL(BASE_URL).hostname;
119- const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`;
115+ const hostname = new URL(config.BASE_URL).hostname;
116+ const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`;
120117
121- if (!existsSync(SSH_HOST_KEY_PATH)) {
122- await Bun.spawn([
118+ if (!existsSync(paths.SSH_HOST_KEY_PATH)) {
119+ const { spawnSync } = await import("node:child_process");
120+ spawnSync(
123121 "ssh-keygen",
124- "-t",
125- "ed25519",
126- "-N",
127- "",
128- "-f",
129- SSH_HOST_KEY_PATH,
130- "-C",
131- hostname,
132- ]).exited;
133- console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
122+ [
123+ "-t",
124+ "ed25519",
125+ "-N",
126+ "",
127+ "-f",
128+ paths.SSH_HOST_KEY_PATH,
129+ "-C",
130+ hostname,
131+ ],
132+ { stdio: "ignore" },
133+ );
134+ console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH);
134135 } else {
135136 try {
136137 const existing = readFileSync(pubKeyPath, "utf8").trim();
@@ -158,19 +159,22 @@ async function ensureSigningSetup(): Promise<void> {
158159
159160 let content = `* namespaces="git" ${pubKey}\n`;
160161
161- if (EXTRA_ALLOWED_SIGNERS_PATH) {
162+ if (config.EXTRA_ALLOWED_SIGNERS_PATH) {
162163 try {
163- const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8");
164+ const extra = readFileSync(
165+ config.EXTRA_ALLOWED_SIGNERS_PATH,
166+ "utf8",
167+ );
164168 content += extra.endsWith("\n") ? extra : `${extra}\n`;
165169 } catch {
166170 console.warn(
167- "Could not read EXTRA_ALLOWED_SIGNERS_PATH:",
168- EXTRA_ALLOWED_SIGNERS_PATH,
171+ "Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:",
172+ config.EXTRA_ALLOWED_SIGNERS_PATH,
169173 );
170174 }
171175 }
172176
173- await Bun.write(ALLOWED_SIGNERS_PATH, content);
177+ await Bun.write(paths.ALLOWED_SIGNERS_PATH, content);
174178 }
175179
176180 export async function syncStartup(): Promise<void> {
@@ -219,13 +223,13 @@ export async function syncStartup(): Promise<void> {
219223 );
220224
221225 try {
222- for (const entry of readdirSync(RELEASES_DIR, {
226+ for (const entry of readdirSync(paths.RELEASES_DIR, {
223227 withFileTypes: true,
224228 })) {
225229 if (!entry.isDirectory()) continue;
226230 const id = Number(entry.name);
227231 if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
228- rmSync(path.join(RELEASES_DIR, entry.name), {
232+ rmSync(path.join(paths.RELEASES_DIR, entry.name), {
229233 recursive: true,
230234 force: true,
231235 });
@@ -235,7 +239,7 @@ export async function syncStartup(): Promise<void> {
235239 }
236240 }
237241 } catch {
238- // RELEASES_DIR may not exist yet on first run
242+ // paths.RELEASES_DIR may not exist yet on first run
239243 }
240244
241245 // Only check for missing dirs on releases that should have one.
@@ -260,7 +264,7 @@ export async function syncStartup(): Promise<void> {
260264 .execute();
261265
262266 const staleReleases = releasesWithDirs.filter(
263- (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
267+ (r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))),
264268 );
265269 if (staleReleases.length > 0) {
266270 await db
Msrc/services/sshServer.ts
@@ -3,8 +3,8 @@ import { createHash } from "node:crypto";
33 import { readFileSync } from "node:fs";
44 import path from "node:path";
55 import { Server, utils } from "ssh2";
6-import { SSH_PORT } from "../config.ts";
7-import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts";
6+import config from "../config.ts";
7+import { ADMIN_USERNAME, paths } from "../constants.ts";
88 import { db } from "../db/index.ts";
99
1010 /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
@@ -33,7 +33,7 @@ export function fingerprintFromLine(pubkeyLine: string): string | null {
3333 }
3434
3535 export async function startSshServer() {
36- const hostKey = readFileSync(SSH_HOST_KEY_PATH);
36+ const hostKey = readFileSync(paths.SSH_HOST_KEY_PATH);
3737
3838 const server = new Server({ hostKeys: [hostKey] }, (client) => {
3939 let authedUser: { id: number; username: string } | null = null;
@@ -94,7 +94,10 @@ export async function startSshServer() {
9494 .executeTakeFirst();
9595 if (!repo) return reject();
9696
97- const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
97+ const repoPath = path.join(
98+ paths.REPOS_DIR,
99+ `${repo.name}.git`,
100+ );
98101 const stream = accept();
99102
100103 if (command === "git-receive-pack") {
@@ -139,7 +142,7 @@ export async function startSshServer() {
139142 });
140143 });
141144
142- server.listen(SSH_PORT, "0.0.0.0", () => {
143- console.log(`SSH server listening on port ${SSH_PORT}`);
145+ server.listen(config.SSH_PORT, "0.0.0.0", () => {
146+ console.log(`SSH server listening on port ${config.SSH_PORT}`);
144147 });
145148 }
Asrc/styles/base.css
@@ -0,0 +1,309 @@
1+/* View Transitions */
2+@view-transition {
3+ navigation: auto;
4+}
5+
6+.site-nav {
7+ view-transition-name: site-nav;
8+}
9+.repo-header {
10+ view-transition-name: repo-header;
11+}
12+.breadcrumb {
13+ view-transition-name: breadcrumb;
14+}
15+
16+/* ============================================================
17+ @layer reset
18+ ============================================================ */
19+@layer reset {
20+ *,
21+ *::before,
22+ *::after {
23+ box-sizing: border-box;
24+ margin: 0;
25+ padding: 0;
26+ }
27+
28+ body {
29+ min-height: 100dvh;
30+ min-width: 500px;
31+ }
32+ img,
33+ video {
34+ max-width: 100%;
35+ height: auto;
36+ }
37+ button,
38+ input,
39+ textarea,
40+ select {
41+ font: inherit;
42+ }
43+ a {
44+ color: inherit;
45+ }
46+ ul,
47+ ol {
48+ list-style: none;
49+ }
50+ h1,
51+ h2,
52+ h3,
53+ h4,
54+ h5,
55+ h6 {
56+ font-weight: 600;
57+ line-height: 1.2;
58+ }
59+ pre,
60+ code {
61+ font-family: var(--font-mono);
62+ }
63+}
64+
65+/* ============================================================
66+ @layer tokens
67+ ============================================================ */
68+@layer tokens {
69+ :root {
70+ /* Colors — light */
71+ --color-bg: #ffffff;
72+ --color-bg-subtle: #f6f8fa;
73+ --color-bg-inset: #eef0f2;
74+ --color-border: #d0d7de;
75+ --color-border-muted: #e4e7eb;
76+ --color-text: #1f2328;
77+ --color-text-muted: #656d76;
78+ --color-link: #0969da;
79+ --color-link-hover: #0550ae;
80+
81+ --color-accent: #fd7e14;
82+ --color-accent-bg: #fff3cd;
83+
84+ --color-success: #1a7f37;
85+ --color-success-bg: #dafbe1;
86+ --color-danger: #cf222e;
87+ --color-danger-bg: #ffebe9;
88+ --color-warning: #9a6700;
89+ --color-warning-bg: #fff8c5;
90+ --color-merged: #8250df;
91+ --color-merged-bg: #fbefff;
92+
93+ /* Typography */
94+ --font-sans:
95+ system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
96+ --font-mono:
97+ ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
98+
99+ --text-xs: 0.75rem;
100+ --text-sm: 0.875rem;
101+ --text-base: 1rem;
102+ --text-lg: 1.125rem;
103+ --text-xl: 1.25rem;
104+ --text-2xl: 1.5rem;
105+ --text-3xl: 1.875rem;
106+
107+ /* Spacing */
108+ --space-1: 0.25rem;
109+ --space-2: 0.5rem;
110+ --space-3: 0.75rem;
111+ --space-4: 1rem;
112+ --space-5: 1.25rem;
113+ --space-6: 1.5rem;
114+ --space-8: 2rem;
115+ --space-10: 2.5rem;
116+ --space-12: 3rem;
117+ --space-16: 4rem;
118+
119+ /* Border radius */
120+ --radius-sm: 4px;
121+ --radius-md: 6px;
122+ --radius-lg: 8px;
123+ --radius-full: 9999px;
124+
125+ /* Shadows */
126+ --shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04);
127+ --shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
128+ --shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2);
129+ }
130+
131+ @media (prefers-color-scheme: dark) {
132+ :root {
133+ --color-bg: #0d1117;
134+ --color-bg-subtle: #161b22;
135+ --color-bg-inset: #1c2128;
136+ --color-border: #30363d;
137+ --color-border-muted: #21262d;
138+ --color-text: #e6edf3;
139+ --color-text-muted: #7d8590;
140+ --color-link: #58a6ff;
141+ --color-link-hover: #79c0ff;
142+
143+ --color-accent: #f0883e;
144+ --color-accent-bg: #3d2f00;
145+
146+ --color-success: #3fb950;
147+ --color-success-bg: #0d2b19;
148+ --color-danger: #f85149;
149+ --color-danger-bg: #2d1419;
150+ --color-warning: #d29922;
151+ --color-warning-bg: #2c2000;
152+ --color-merged: #bc8cff;
153+ --color-merged-bg: #1e0b3e;
154+ }
155+ }
156+
157+ /* Explicit theme overrides — applied via data-theme attribute set by cookie */
158+ :root[data-theme="dark"] {
159+ --color-bg: #0d1117;
160+ --color-bg-subtle: #161b22;
161+ --color-bg-inset: #1c2128;
162+ --color-border: #30363d;
163+ --color-border-muted: #21262d;
164+ --color-text: #e6edf3;
165+ --color-text-muted: #7d8590;
166+ --color-link: #58a6ff;
167+ --color-link-hover: #79c0ff;
168+ --color-accent: #f0883e;
169+ --color-accent-bg: #3d2f00;
170+ --color-success: #3fb950;
171+ --color-success-bg: #0d2b19;
172+ --color-danger: #f85149;
173+ --color-danger-bg: #2d1419;
174+ --color-warning: #d29922;
175+ --color-warning-bg: #2c2000;
176+ --color-merged: #bc8cff;
177+ --color-merged-bg: #1e0b3e;
178+ }
179+ :root[data-theme="light"] {
180+ --color-bg: #ffffff;
181+ --color-bg-subtle: #f6f8fa;
182+ --color-bg-inset: #eef0f2;
183+ --color-border: #d0d7de;
184+ --color-border-muted: #e4e7eb;
185+ --color-text: #1f2328;
186+ --color-text-muted: #656d76;
187+ --color-link: #0969da;
188+ --color-link-hover: #0550ae;
189+ --color-accent: #fd7e14;
190+ --color-accent-bg: #fff3cd;
191+ --color-success: #1a7f37;
192+ --color-success-bg: #dafbe1;
193+ --color-danger: #cf222e;
194+ --color-danger-bg: #ffebe9;
195+ --color-warning: #9a6700;
196+ --color-warning-bg: #fff8c5;
197+ --color-merged: #8250df;
198+ --color-merged-bg: #fbefff;
199+ }
200+}
201+
202+/* ============================================================
203+ @layer layout
204+ ============================================================ */
205+@layer layout {
206+ body {
207+ font-family: var(--font-sans);
208+ font-size: var(--text-base);
209+ color: var(--color-text);
210+ background: var(--color-bg);
211+ display: grid;
212+ grid-template-rows: auto 1fr auto;
213+ min-height: 100dvh;
214+ }
215+
216+ .site-header {
217+ background: var(--color-bg-subtle);
218+ border-bottom: 1px solid var(--color-border);
219+ position: sticky;
220+ top: 0;
221+ z-index: 100;
222+ }
223+
224+ .site-nav {
225+ max-width: 1200px;
226+ margin: 0 auto;
227+ padding: var(--space-3) var(--space-4);
228+ display: flex;
229+ align-items: center;
230+ gap: var(--space-4);
231+ }
232+
233+ .site-logo {
234+ display: flex;
235+ align-items: center;
236+ gap: var(--space-2);
237+ text-decoration: none;
238+ font-weight: 700;
239+ font-size: var(--text-lg);
240+ color: var(--color-text);
241+ }
242+
243+ .logo-icon {
244+ width: 1.6em;
245+ height: 1.6em;
246+ display: block;
247+ flex-shrink: 0;
248+ }
249+
250+ .nav-links {
251+ margin-left: auto;
252+ display: flex;
253+ align-items: center;
254+ gap: var(--space-3);
255+ }
256+
257+ .site-main {
258+ padding: var(--space-8) var(--space-4);
259+ min-width: 0;
260+ }
261+
262+ .site-footer {
263+ text-align: center;
264+ padding: var(--space-6) var(--space-4);
265+ font-size: var(--text-sm);
266+ color: var(--color-text-muted);
267+ border-top: 1px solid var(--color-border);
268+ }
269+
270+ .container {
271+ max-width: 1000px;
272+ margin: 0 auto;
273+ overflow-x: clip;
274+ }
275+
276+ .container-narrow {
277+ max-width: 680px;
278+ }
279+
280+ .inline-form {
281+ display: inline;
282+ }
283+
284+ /* --- Two-step delete confirmation --- */
285+ .confirm-details {
286+ position: relative;
287+ display: inline-block;
288+ }
289+ .confirm-details > summary {
290+ list-style: none;
291+ }
292+
293+ .confirm-popup {
294+ position: absolute;
295+ right: 0;
296+ top: calc(100% + var(--space-1));
297+ background: var(--color-bg);
298+ border: 1px solid var(--color-border);
299+ border-radius: var(--radius-md);
300+ padding: var(--space-3) var(--space-4);
301+ display: flex;
302+ align-items: center;
303+ gap: var(--space-3);
304+ white-space: nowrap;
305+ z-index: 100;
306+ font-size: var(--text-sm);
307+ color: var(--color-text-muted);
308+ }
309+}
Asrc/styles/code.css
@@ -0,0 +1,714 @@
1+@layer components {
2+ /* --- File tree --- */
3+ .file-tree {
4+ width: 100%;
5+ border: 1px solid var(--color-border);
6+ border-radius: var(--radius-lg);
7+ border-collapse: collapse;
8+ overflow: hidden;
9+ margin-bottom: var(--space-6);
10+ }
11+ .file-tree-row {
12+ border-bottom: 1px solid var(--color-border-muted);
13+ }
14+ .file-tree-row:last-child {
15+ border-bottom: none;
16+ }
17+ .file-tree-row:hover {
18+ background: var(--color-bg-subtle);
19+ }
20+ .file-icon,
21+ .file-name,
22+ .file-size {
23+ padding: var(--space-2) var(--space-3);
24+ font-size: var(--text-sm);
25+ vertical-align: middle;
26+ }
27+ .file-icon {
28+ width: 36px;
29+ padding-right: var(--space-1);
30+ text-align: center;
31+ line-height: 0;
32+ }
33+ .file-name {
34+ width: 100%;
35+ }
36+ .file-name a {
37+ color: var(--color-link);
38+ text-decoration: none;
39+ }
40+ .file-name a:hover {
41+ text-decoration: underline;
42+ }
43+ .file-name a.file-name-dir {
44+ font-weight: 500;
45+ }
46+ .file-size {
47+ text-align: right;
48+ color: var(--color-text-muted);
49+ font-size: var(--text-xs);
50+ white-space: nowrap;
51+ padding-right: var(--space-4);
52+ }
53+ .file-icon-dir {
54+ color: var(--color-link);
55+ }
56+ .file-icon-file {
57+ color: var(--color-text-muted);
58+ }
59+ .tree-icon {
60+ display: inline-block;
61+ vertical-align: text-bottom;
62+ }
63+ .file-tree-row-up .file-name a {
64+ color: var(--color-text-muted);
65+ }
66+ .file-tree-row-up .file-name a:hover {
67+ color: var(--color-link);
68+ }
69+
70+ /* --- File blob --- */
71+ .file-blob-header {
72+ display: flex;
73+ align-items: center;
74+ justify-content: space-between;
75+ padding: var(--space-3) var(--space-4);
76+ background: var(--color-bg-subtle);
77+ border: 1px solid var(--color-border);
78+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
79+ margin-top: var(--space-4);
80+ }
81+ .file-blob-name {
82+ font-size: var(--text-sm);
83+ font-weight: 500;
84+ }
85+ .file-blob-actions {
86+ display: flex;
87+ align-items: center;
88+ gap: var(--space-2);
89+ }
90+ .file-blob-body {
91+ border: 1px solid var(--color-border);
92+ border-top: none;
93+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
94+ overflow: auto;
95+ }
96+ .file-edit-textarea {
97+ display: block;
98+ width: 100%;
99+ box-sizing: border-box;
100+ font-family: var(--font-mono);
101+ font-size: var(--text-sm);
102+ line-height: 1.6;
103+ padding: var(--space-4);
104+ background: var(--color-bg);
105+ color: var(--color-text);
106+ border: none;
107+ resize: vertical;
108+ min-height: 400px;
109+ }
110+ .file-edit-textarea:focus {
111+ outline: 2px solid var(--color-accent);
112+ outline-offset: -2px;
113+ }
114+ /* Blob line-number table */
115+ .blob-table {
116+ min-width: 100%;
117+ border-collapse: collapse;
118+ font-family: var(--font-mono);
119+ font-size: var(--text-sm);
120+ line-height: 1.6;
121+ background-color: var(--shiki-light-bg, #fff);
122+ color: var(--shiki-light, inherit);
123+ }
124+ .blob-ln {
125+ width: 1%;
126+ min-width: 48px;
127+ text-align: right;
128+ padding: 0 var(--space-3);
129+ color: var(--color-text-muted);
130+ border-right: 1px solid var(--color-border-muted);
131+ user-select: none;
132+ white-space: nowrap;
133+ vertical-align: top;
134+ }
135+ .blob-ln a {
136+ color: inherit;
137+ text-decoration: none;
138+ }
139+ .blob-ln a:hover {
140+ color: var(--color-link);
141+ }
142+ .blob-code {
143+ padding: 0 var(--space-4);
144+ white-space: pre;
145+ overflow: visible;
146+ vertical-align: top;
147+ }
148+ .blob-table tr:target {
149+ background: color-mix(in srgb, #ddf4ff 60%, transparent);
150+ }
151+ .blob-table tr:target .blob-ln {
152+ background: color-mix(in srgb, #ddf4ff 80%, transparent);
153+ }
154+ .file-download-notice {
155+ padding: var(--space-8);
156+ text-align: center;
157+ color: var(--color-text-muted);
158+ }
159+ .file-download-notice p {
160+ margin-bottom: var(--space-4);
161+ }
162+ .file-media {
163+ padding: var(--space-4);
164+ display: flex;
165+ justify-content: center;
166+ background: var(--color-bg-subtle);
167+ }
168+ .file-media-img {
169+ max-width: 100%;
170+ height: auto;
171+ display: block;
172+ }
173+ .file-media-audio {
174+ width: 100%;
175+ max-width: 600px;
176+ }
177+ .file-media-video {
178+ max-width: 100%;
179+ max-height: 80vh;
180+ }
181+
182+ /* --- Commit list --- */
183+ .commit-list {
184+ display: flex;
185+ flex-direction: column;
186+ }
187+ .commit-item {
188+ display: flex;
189+ flex-direction: column;
190+ padding: var(--space-3) 0;
191+ border-bottom: 1px solid var(--color-border-muted);
192+ gap: var(--space-2);
193+ }
194+ .commit-item:last-child {
195+ border-bottom: none;
196+ }
197+ .commit-subject {
198+ color: var(--color-text);
199+ text-decoration: none;
200+ font-size: var(--text-sm);
201+ font-weight: 500;
202+ }
203+ .commit-subject:hover {
204+ color: var(--color-link);
205+ }
206+ .commit-meta {
207+ display: flex;
208+ align-items: center;
209+ justify-content: space-between;
210+ gap: var(--space-3);
211+ font-size: var(--text-xs);
212+ color: var(--color-text-muted);
213+ }
214+ .commit-meta-right {
215+ display: flex;
216+ align-items: center;
217+ gap: var(--space-3);
218+ white-space: nowrap;
219+ }
220+ .commit-hash {
221+ font-family: var(--font-mono);
222+ color: var(--color-link);
223+ text-decoration: none;
224+ background: var(--color-bg-inset);
225+ padding: 1px var(--space-2);
226+ border-radius: var(--radius-sm);
227+ }
228+ .commit-hash:hover {
229+ text-decoration: underline;
230+ }
231+ .mono {
232+ font-family: var(--font-mono);
233+ }
234+
235+ /* --- Commit detail viewer --- */
236+ .commit-page-top {
237+ display: flex;
238+ align-items: center;
239+ justify-content: space-between;
240+ margin-bottom: var(--space-4);
241+ }
242+ .commit-card {
243+ border: 1px solid var(--color-border);
244+ border-radius: var(--radius-lg);
245+ margin-bottom: var(--space-4);
246+ overflow: hidden;
247+ }
248+ .commit-card-subject {
249+ font-size: var(--text-xl);
250+ font-weight: 600;
251+ padding: var(--space-5) var(--space-6);
252+ border-bottom: 1px solid var(--color-border);
253+ line-height: 1.3;
254+ }
255+ .commit-card-subject-empty {
256+ color: var(--color-text-muted);
257+ font-style: italic;
258+ font-weight: 400;
259+ font-size: var(--text-sm);
260+ }
261+ .commit-card-body {
262+ padding: var(--space-4) var(--space-6);
263+ font-family: inherit;
264+ font-size: var(--text-sm);
265+ color: var(--color-text-muted);
266+ border-bottom: 1px solid var(--color-border);
267+ overflow-x: auto;
268+ }
269+ .commit-card-meta {
270+ padding: var(--space-4) var(--space-6);
271+ display: flex;
272+ flex-direction: column;
273+ gap: var(--space-2);
274+ background: var(--color-bg-subtle);
275+ }
276+ .commit-card-meta-row {
277+ display: flex;
278+ align-items: baseline;
279+ gap: var(--space-3);
280+ font-size: var(--text-sm);
281+ flex-wrap: wrap;
282+ }
283+ .commit-meta-label {
284+ font-weight: 600;
285+ color: var(--color-text-muted);
286+ min-width: 5rem;
287+ font-size: var(--text-xs);
288+ text-transform: uppercase;
289+ letter-spacing: 0.04em;
290+ }
291+ .commit-sha-full {
292+ font-size: var(--text-xs);
293+ background: var(--color-bg-inset);
294+ padding: 2px var(--space-2);
295+ border-radius: var(--radius-sm);
296+ word-break: break-all;
297+ }
298+ .commit-stats-bar {
299+ font-size: var(--text-sm);
300+ color: var(--color-text-muted);
301+ margin-bottom: var(--space-4);
302+ }
303+
304+ /* --- Commit layout (sidebar + diffs) --- */
305+ .commit-layout {
306+ display: grid;
307+ grid-template-columns: 1fr;
308+ gap: var(--space-5);
309+ align-items: start;
310+ }
311+ .commit-diffs {
312+ min-width: 0;
313+ }
314+ @media (min-width: 900px) {
315+ .commit-layout {
316+ grid-template-columns: 220px 1fr;
317+ }
318+ .commit-layout:has(.file-nav-details:not([open])) {
319+ grid-template-columns: auto 1fr;
320+ }
321+ }
322+
323+ /* --- File nav sidebar --- */
324+ .commit-file-nav {
325+ position: sticky;
326+ top: calc(56px + var(--space-4));
327+ max-height: calc(100vh - 140px);
328+ overflow-y: auto;
329+ }
330+ .file-nav-details {
331+ border: 1px solid var(--color-border);
332+ border-radius: var(--radius-lg);
333+ background: var(--color-bg-subtle);
334+ overflow: hidden;
335+ }
336+ .file-nav-toggle {
337+ display: flex;
338+ align-items: center;
339+ gap: var(--space-2);
340+ padding: var(--space-3) var(--space-4);
341+ font-size: var(--text-sm);
342+ font-weight: 500;
343+ cursor: pointer;
344+ list-style: none;
345+ user-select: none;
346+ border-bottom: 1px solid var(--color-border);
347+ }
348+
349+ .file-nav-details:not([open]) .file-nav-toggle {
350+ border-bottom: none;
351+ }
352+ .file-nav-toggle-icon {
353+ font-size: 1em;
354+ transition: transform 0.15s;
355+ }
356+ .file-nav-details:not([open]) .file-nav-toggle-icon {
357+ transform: rotate(-90deg);
358+ }
359+ .file-nav-list {
360+ padding: var(--space-1) 0;
361+ list-style: none;
362+ }
363+ .file-nav-item {
364+ display: flex;
365+ align-items: center;
366+ gap: var(--space-2);
367+ padding: var(--space-1) var(--space-3);
368+ font-size: var(--text-xs);
369+ text-decoration: none;
370+ color: var(--color-text);
371+ border-radius: 0;
372+ overflow: hidden;
373+ }
374+ .file-nav-item:hover {
375+ background: var(--color-bg-inset);
376+ }
377+ .file-nav-status {
378+ flex-shrink: 0;
379+ width: 16px;
380+ height: 16px;
381+ display: inline-flex;
382+ align-items: center;
383+ justify-content: center;
384+ border-radius: var(--radius-sm);
385+ font-size: 9px;
386+ font-weight: 700;
387+ font-family: var(--font-mono);
388+ }
389+ .file-status-added {
390+ background: var(--color-success-bg);
391+ color: var(--color-success);
392+ }
393+ .file-status-deleted {
394+ background: var(--color-danger-bg);
395+ color: var(--color-danger);
396+ }
397+ .file-status-modified {
398+ background: var(--color-accent-bg);
399+ color: var(--color-accent);
400+ }
401+ .file-status-renamed {
402+ background: var(--color-merged-bg);
403+ color: var(--color-merged);
404+ }
405+ .file-status-copied {
406+ background: var(--color-merged-bg);
407+ color: var(--color-merged);
408+ }
409+ .file-nav-name {
410+ flex: 1;
411+ overflow: hidden;
412+ text-overflow: ellipsis;
413+ white-space: nowrap;
414+ font-family: var(--font-mono);
415+ }
416+ .file-nav-stat {
417+ flex-shrink: 0;
418+ display: flex;
419+ gap: var(--space-1);
420+ font-size: var(--text-xs);
421+ font-family: var(--font-mono);
422+ }
423+ .nav-add {
424+ color: var(--color-success);
425+ }
426+ .nav-del {
427+ color: var(--color-danger);
428+ }
429+ .nav-binary {
430+ color: var(--color-text-muted);
431+ font-size: var(--text-xs);
432+ }
433+ .file-nav-dir > details > .file-nav-list {
434+ padding-left: var(--space-4);
435+ }
436+ .file-nav-dir-toggle {
437+ display: flex;
438+ align-items: center;
439+ gap: var(--space-1);
440+ padding: var(--space-1) var(--space-3);
441+ font-size: var(--text-xs);
442+ font-family: var(--font-mono);
443+ color: var(--color-text-muted);
444+ cursor: pointer;
445+ list-style: none;
446+ user-select: none;
447+ }
448+
449+ .file-nav-dir > details:not([open]) .file-nav-toggle-icon {
450+ transform: rotate(-90deg);
451+ }
452+
453+ /* --- Diff file cards --- */
454+ .diff-file {
455+ border: 1px solid var(--color-border);
456+ border-radius: var(--radius-lg);
457+ margin-bottom: var(--space-4);
458+ overflow: hidden;
459+ }
460+ .diff-file-header {
461+ display: flex;
462+ align-items: center;
463+ justify-content: space-between;
464+ gap: var(--space-3);
465+ padding: var(--space-3) var(--space-4);
466+ background: var(--color-bg-subtle);
467+ cursor: pointer;
468+ list-style: none;
469+ user-select: none;
470+ flex-wrap: wrap;
471+ }
472+
473+ .diff-file-header-left {
474+ display: flex;
475+ align-items: center;
476+ gap: var(--space-2);
477+ min-width: 0;
478+ flex: 1;
479+ }
480+ .diff-file-header-right {
481+ display: flex;
482+ align-items: center;
483+ gap: var(--space-2);
484+ flex-shrink: 0;
485+ flex-wrap: wrap;
486+ }
487+ .diff-file-toggle-icon {
488+ font-size: 0.7em;
489+ flex-shrink: 0;
490+ transition: transform 0.15s;
491+ }
492+ .diff-file:not([open]) .diff-file-toggle-icon {
493+ transform: rotate(-90deg);
494+ }
495+ .diff-status-badge {
496+ flex-shrink: 0;
497+ width: 18px;
498+ height: 18px;
499+ display: inline-flex;
500+ align-items: center;
501+ justify-content: center;
502+ border-radius: var(--radius-sm);
503+ font-size: 10px;
504+ font-weight: 700;
505+ font-family: var(--font-mono);
506+ }
507+ .diff-status-added {
508+ background: var(--color-success-bg);
509+ color: var(--color-success);
510+ }
511+ .diff-status-deleted {
512+ background: var(--color-danger-bg);
513+ color: var(--color-danger);
514+ }
515+ .diff-status-modified {
516+ background: var(--color-accent-bg);
517+ color: var(--color-accent);
518+ }
519+ .diff-status-renamed {
520+ background: var(--color-merged-bg);
521+ color: var(--color-merged);
522+ }
523+ .diff-status-copied {
524+ background: var(--color-merged-bg);
525+ color: var(--color-merged);
526+ }
527+ .diff-file-path {
528+ font-size: var(--text-sm);
529+ font-weight: 500;
530+ overflow: hidden;
531+ text-overflow: ellipsis;
532+ white-space: nowrap;
533+ }
534+ .diff-rename-arrow {
535+ font-size: var(--text-xs);
536+ color: var(--color-text-muted);
537+ white-space: nowrap;
538+ overflow: hidden;
539+ text-overflow: ellipsis;
540+ }
541+ .diff-stat-add {
542+ color: var(--color-success);
543+ font-size: var(--text-sm);
544+ font-family: var(--font-mono);
545+ font-weight: 600;
546+ }
547+ .diff-stat-del {
548+ color: var(--color-danger);
549+ font-size: var(--text-sm);
550+ font-family: var(--font-mono);
551+ font-weight: 600;
552+ }
553+ .diff-stat-binary {
554+ color: var(--color-text-muted);
555+ font-size: var(--text-sm);
556+ font-family: var(--font-mono);
557+ }
558+ .btn-xs {
559+ padding: 1px var(--space-2);
560+ font-size: var(--text-xs);
561+ border: 1px solid var(--color-border);
562+ border-radius: var(--radius-sm);
563+ background: var(--color-bg);
564+ color: var(--color-text);
565+ text-decoration: none;
566+ cursor: pointer;
567+ white-space: nowrap;
568+ font-family: var(--font-mono);
569+ display: inline-flex;
570+ align-items: center;
571+ transition: background 0.1s;
572+ }
573+ .btn-xs:hover {
574+ background: var(--color-bg-inset);
575+ }
576+
577+ /* --- Diff file body --- */
578+ .diff-binary-notice {
579+ padding: var(--space-4) var(--space-6);
580+ font-size: var(--text-sm);
581+ color: var(--color-text-muted);
582+ font-style: italic;
583+ }
584+ .diff-hunk {
585+ border-top: 1px solid var(--color-border-muted);
586+ overflow-x: auto;
587+ }
588+ .diff-hunk:first-child {
589+ border-top: none;
590+ }
591+ .diff-hunk-header {
592+ padding: var(--space-1) var(--space-4);
593+ font-family: var(--font-mono);
594+ font-size: var(--text-xs);
595+ font-weight: normal;
596+ text-align: left;
597+ background: color-mix(
598+ in srgb,
599+ var(--color-merged-bg) 50%,
600+ var(--color-bg)
601+ );
602+ color: var(--color-merged);
603+ border-bottom: 1px solid var(--color-border-muted);
604+ white-space: pre;
605+ }
606+
607+ /* --- Diff table --- */
608+ .diff-table {
609+ min-width: 100%;
610+ border-collapse: collapse;
611+ font-family: var(--font-mono);
612+ font-size: var(--text-xs);
613+ line-height: 1.5;
614+ }
615+ .diff-ln {
616+ width: 44px;
617+ min-width: 44px;
618+ text-align: right;
619+ padding: 0 var(--space-2);
620+ color: var(--color-text-muted);
621+ border-right: 1px solid var(--color-border-muted);
622+ user-select: none;
623+ vertical-align: top;
624+ white-space: nowrap;
625+ }
626+ .diff-sign {
627+ width: 18px;
628+ min-width: 18px;
629+ text-align: center;
630+ padding: 0 var(--space-1);
631+ user-select: none;
632+ vertical-align: top;
633+ }
634+ .diff-code {
635+ padding: 0 var(--space-3);
636+ white-space: pre;
637+ overflow: visible;
638+ vertical-align: top;
639+ word-break: normal;
640+ }
641+ /* Row type backgrounds */
642+ .diff-row-add {
643+ background: color-mix(
644+ in srgb,
645+ var(--color-success-bg) 70%,
646+ transparent
647+ );
648+ }
649+ .diff-row-del {
650+ background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent);
651+ }
652+ .diff-row-add .diff-ln,
653+ .diff-row-add .diff-sign {
654+ background: color-mix(
655+ in srgb,
656+ var(--color-success-bg) 90%,
657+ transparent
658+ );
659+ }
660+ .diff-row-del .diff-ln,
661+ .diff-row-del .diff-sign {
662+ background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent);
663+ }
664+ .diff-row-add .diff-sign {
665+ color: var(--color-success);
666+ font-weight: 700;
667+ }
668+ .diff-row-del .diff-sign {
669+ color: var(--color-danger);
670+ font-weight: 700;
671+ }
672+
673+ .diff-ctrl {
674+ color: #f0f0f0;
675+ background: #c0392b;
676+ border-radius: 2px;
677+ font-weight: bold;
678+ padding: 0 1px;
679+ }
680+
681+ /* Shiki dark-mode support in diff code cells */
682+ @media (prefers-color-scheme: dark) {
683+ .diff-code span[style] {
684+ /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
685+ color: var(--shiki-dark) !important;
686+ }
687+ }
688+
689+ /* Shiki dark-mode support in file blob view */
690+ @media (prefers-color-scheme: dark) {
691+ .blob-table {
692+ background-color: var(--shiki-dark-bg);
693+ color: var(--shiki-dark);
694+ }
695+ .blob-table span[style] {
696+ /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
697+ color: var(--shiki-dark) !important;
698+ }
699+ .blob-table tr:target {
700+ background: color-mix(in srgb, #1c3a52 60%, transparent);
701+ }
702+ .blob-table tr:target .blob-ln {
703+ background: color-mix(in srgb, #1c3a52 80%, transparent);
704+ }
705+ }
706+ :root[data-theme="dark"] .blob-table {
707+ background-color: var(--shiki-dark-bg);
708+ color: var(--shiki-dark);
709+ }
710+ :root[data-theme="dark"] .blob-table span[style] {
711+ /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
712+ color: var(--shiki-dark) !important;
713+ }
714+}
Asrc/styles/components.css
@@ -0,0 +1,1798 @@
1+@layer components {
2+ /* --- Buttons --- */
3+ .btn {
4+ display: inline-flex;
5+ align-items: center;
6+ gap: var(--space-2);
7+ padding: var(--space-2) var(--space-4);
8+ border: 1px solid var(--color-border);
9+ border-radius: var(--radius-md);
10+ font-size: var(--text-sm);
11+ font-weight: 500;
12+ text-decoration: none;
13+ cursor: pointer;
14+ background: var(--color-bg-subtle);
15+ color: var(--color-text);
16+ transition:
17+ background 0.1s,
18+ border-color 0.1s;
19+ white-space: nowrap;
20+ }
21+ .btn:hover {
22+ background: var(--color-bg-inset);
23+ border-color: var(--color-border);
24+ }
25+ .btn:disabled {
26+ opacity: 0.5;
27+ cursor: not-allowed;
28+ }
29+ .btn-primary {
30+ background: var(--color-accent);
31+ border-color: transparent;
32+ color: #fff;
33+ }
34+ .btn-primary:hover {
35+ background: color-mix(in srgb, var(--color-accent) 82%, #000);
36+ }
37+ .btn-secondary {
38+ background: var(--color-bg-subtle);
39+ border-color: var(--color-border);
40+ }
41+ .btn-sm {
42+ padding: var(--space-1) var(--space-3);
43+ font-size: var(--text-xs);
44+ }
45+ .btn-block {
46+ width: 100%;
47+ justify-content: center;
48+ }
49+
50+ /* --- Forms --- */
51+ .form-group {
52+ display: flex;
53+ flex-direction: column;
54+ gap: var(--space-2);
55+ margin-bottom: var(--space-4);
56+ }
57+ .form-group label,
58+ .form-group .form-group-label {
59+ font-size: var(--text-sm);
60+ font-weight: 500;
61+ color: var(--color-text);
62+ }
63+ .form-group input:not([type="file"]):not([type="checkbox"]),
64+ .form-group textarea,
65+ .form-group select {
66+ padding: var(--space-2) var(--space-3);
67+ border: 1px solid var(--color-border);
68+ border-radius: var(--radius-md);
69+ background: var(--color-bg);
70+ color: var(--color-text);
71+ font-size: var(--text-sm);
72+ }
73+ .form-group input:not([type="file"]):not([type="checkbox"]):focus,
74+ .form-group textarea:focus {
75+ outline: 2px solid var(--color-accent);
76+ outline-offset: -1px;
77+ border-color: var(--color-accent);
78+ }
79+ .form-group input[type="file"] {
80+ font-size: var(--text-sm);
81+ color: var(--color-text);
82+ padding: 0;
83+ border: none;
84+ background: none;
85+ }
86+ .form-group textarea {
87+ resize: vertical;
88+ min-height: 120px;
89+ }
90+ .form-success {
91+ background: var(--color-success-bg);
92+ color: var(--color-success);
93+ border: 1px solid var(--color-success);
94+ border-radius: var(--radius-md);
95+ padding: var(--space-3) var(--space-4);
96+ font-size: var(--text-sm);
97+ margin-bottom: var(--space-4);
98+ }
99+ .form-hint {
100+ font-size: var(--text-sm);
101+ color: var(--color-text-muted);
102+ margin: 0;
103+ }
104+ .form-error {
105+ background: var(--color-danger-bg);
106+ color: var(--color-danger);
107+ border: 1px solid var(--color-danger);
108+ border-radius: var(--radius-md);
109+ padding: var(--space-3) var(--space-4);
110+ font-size: var(--text-sm);
111+ margin-bottom: var(--space-4);
112+ }
113+ .form-actions {
114+ display: flex;
115+ gap: var(--space-3);
116+ align-items: center;
117+ flex-wrap: wrap;
118+ }
119+ .form-card {
120+ background: var(--color-bg-subtle);
121+ border: 1px solid var(--color-border);
122+ border-radius: var(--radius-lg);
123+ padding: var(--space-6);
124+ margin-top: var(--space-4);
125+ }
126+ .checkbox-label {
127+ display: flex;
128+ align-items: center;
129+ gap: var(--space-2);
130+ cursor: pointer;
131+ }
132+
133+ /* --- Page header --- */
134+ .page-header {
135+ display: flex;
136+ align-items: center;
137+ justify-content: space-between;
138+ margin-bottom: var(--space-6);
139+ gap: var(--space-4);
140+ }
141+ .page-title {
142+ font-size: var(--text-2xl);
143+ color: var(--color-text);
144+ margin-bottom: var(--space-4);
145+ }
146+ .page-title a {
147+ text-decoration: none;
148+ color: var(--color-link);
149+ }
150+ .page-title a:hover {
151+ text-decoration: underline;
152+ }
153+ .section-title {
154+ font-size: var(--text-lg);
155+ margin-bottom: var(--space-4);
156+ padding-bottom: var(--space-2);
157+ border-bottom: 1px solid var(--color-border);
158+ }
159+
160+ /* --- Auth --- */
161+ .auth-container {
162+ max-width: 400px;
163+ margin: var(--space-12) auto;
164+ }
165+ .auth-container .page-title {
166+ margin-bottom: var(--space-6);
167+ }
168+ .auth-form {
169+ margin-bottom: var(--space-4);
170+ }
171+ .auth-footer {
172+ text-align: center;
173+ font-size: var(--text-sm);
174+ color: var(--color-text-muted);
175+ margin-top: var(--space-4);
176+ }
177+ .passkey-btn {
178+ display: none;
179+ margin-top: var(--space-3);
180+ }
181+
182+ /* --- Repo header --- */
183+ .repo-header {
184+ margin-bottom: var(--space-2);
185+ }
186+ .repo-title-row {
187+ display: flex;
188+ align-items: center;
189+ gap: var(--space-3);
190+ flex-wrap: wrap;
191+ }
192+ .repo-title-row .page-title {
193+ margin-bottom: 0;
194+ }
195+ .repo-header-title {
196+ display: flex;
197+ align-items: center;
198+ gap: var(--space-3);
199+ flex-wrap: wrap;
200+ }
201+ .repo-description {
202+ color: var(--color-text-muted);
203+ margin-top: var(--space-2);
204+ }
205+ .repo-header-meta {
206+ display: flex;
207+ align-items: center;
208+ gap: var(--space-4);
209+ margin-bottom: var(--space-3);
210+ font-size: var(--text-sm);
211+ color: var(--color-text-muted);
212+ }
213+ .icon {
214+ font-style: normal;
215+ }
216+
217+ /* --- Branch selector --- */
218+ .branch-selector {
219+ display: inline-flex;
220+ align-items: center;
221+ gap: var(--space-2);
222+ }
223+ .branch-selector-icon {
224+ color: var(--color-text-muted);
225+ font-size: var(--text-sm);
226+ user-select: none;
227+ }
228+ .branch-select {
229+ padding: var(--space-1) var(--space-2);
230+ padding-right: var(--space-5);
231+ border: 1px solid var(--color-border);
232+ border-radius: var(--radius-md);
233+ background: var(--color-bg-subtle);
234+ font-size: var(--text-sm);
235+ font-family: var(--font-mono);
236+ color: var(--color-text);
237+ cursor: pointer;
238+ appearance: auto;
239+ max-width: 180px;
240+ }
241+ .branch-select:hover {
242+ border-color: var(--color-text-muted);
243+ }
244+ .branch-select:focus {
245+ outline: 2px solid var(--color-accent);
246+ outline-offset: -1px;
247+ }
248+
249+ /* toolbar row above file tree */
250+ .tree-toolbar {
251+ display: flex;
252+ align-items: center;
253+ justify-content: space-between;
254+ margin-bottom: var(--space-3);
255+ }
256+
257+ /* commits-header flex row */
258+ .commits-header {
259+ display: flex;
260+ align-items: center;
261+ gap: var(--space-4);
262+ margin-bottom: var(--space-4);
263+ flex-wrap: wrap;
264+ }
265+ .commits-header .section-title {
266+ margin-bottom: 0;
267+ }
268+
269+ /* --- Repo nav bar (tabs + private badge) --- */
270+ .repo-nav-bar {
271+ display: flex;
272+ align-items: center;
273+ justify-content: space-between;
274+ border-bottom: 1px solid var(--color-border);
275+ margin-bottom: var(--space-6);
276+ gap: var(--space-4);
277+ }
278+
279+ /* --- Repo tabs --- */
280+ .repo-tabs {
281+ display: flex;
282+ gap: 0;
283+ flex: 1;
284+ overflow-x: auto;
285+ overflow-y: hidden;
286+ }
287+ .repo-tab {
288+ padding: var(--space-3) var(--space-4);
289+ font-size: var(--text-sm);
290+ text-decoration: none;
291+ color: var(--color-text-muted);
292+ border-bottom: 2px solid transparent;
293+ margin-bottom: -1px;
294+ transition: color 0.1s;
295+ }
296+ .repo-tab:hover {
297+ color: var(--color-text);
298+ }
299+ .repo-tab.active {
300+ color: var(--color-text);
301+ font-weight: 600;
302+ border-bottom-color: var(--color-accent);
303+ }
304+
305+ /* --- Repo list --- */
306+ .repo-list {
307+ display: flex;
308+ flex-direction: column;
309+ gap: var(--space-2);
310+ }
311+ .repo-card {
312+ display: flex;
313+ align-items: flex-start;
314+ justify-content: space-between;
315+ padding: var(--space-4) var(--space-5);
316+ border: 1px solid var(--color-border);
317+ border-radius: var(--radius-lg);
318+ background: var(--color-bg);
319+ gap: var(--space-4);
320+ transition: border-color 0.15s;
321+ }
322+ .repo-card:hover {
323+ border-color: var(--color-accent);
324+ }
325+ .repo-card-main {
326+ flex: 1;
327+ min-width: 0;
328+ }
329+ .repo-card-title {
330+ display: flex;
331+ align-items: center;
332+ gap: var(--space-2);
333+ margin-bottom: var(--space-1);
334+ }
335+ .repo-name {
336+ font-size: var(--text-lg);
337+ font-weight: 600;
338+ color: var(--color-link);
339+ text-decoration: none;
340+ word-break: break-all;
341+ }
342+ .repo-name:hover {
343+ text-decoration: underline;
344+ }
345+ .repo-card-meta {
346+ display: flex;
347+ flex-direction: column;
348+ align-items: flex-end;
349+ gap: var(--space-1);
350+ font-size: var(--text-xs);
351+ color: var(--color-text-muted);
352+ white-space: nowrap;
353+ }
354+ .repo-date {
355+ color: var(--color-text-muted);
356+ }
357+
358+ /* --- Badges --- */
359+ .badge {
360+ display: inline-flex;
361+ align-items: center;
362+ padding: 1px var(--space-2);
363+ border-radius: var(--radius-full);
364+ font-size: var(--text-xs);
365+ font-weight: 500;
366+ border: 1px solid;
367+ text-decoration: none;
368+ }
369+ .badge-private {
370+ color: var(--color-warning);
371+ border-color: var(--color-warning);
372+ background: var(--color-warning-bg);
373+ }
374+ .badge-pinned {
375+ color: var(--color-accent);
376+ border-color: var(--color-accent);
377+ background: var(--color-accent-bg);
378+ }
379+ .issue-badge,
380+ .patch-badge {
381+ display: inline-flex;
382+ align-items: center;
383+ padding: var(--space-1) var(--space-3);
384+ border-radius: var(--radius-full);
385+ font-size: var(--text-xs);
386+ font-weight: 500;
387+ text-transform: capitalize;
388+ }
389+ .issue-badge.open,
390+ .patch-badge.open {
391+ background: var(--color-success-bg);
392+ color: var(--color-success);
393+ }
394+ .issue-badge.closed,
395+ .patch-badge.closed {
396+ background: var(--color-danger-bg);
397+ color: var(--color-danger);
398+ }
399+ .issue-badge.completed {
400+ background: var(--color-merged-bg);
401+ color: var(--color-merged);
402+ }
403+ .patch-badge.merged {
404+ background: var(--color-merged-bg);
405+ color: var(--color-merged);
406+ }
407+ .sig-badge {
408+ display: inline-flex;
409+ align-items: center;
410+ padding: var(--space-1) var(--space-3);
411+ border-radius: var(--radius-full);
412+ font-size: var(--text-xs);
413+ font-weight: 500;
414+ }
415+ .sig-badge.verified {
416+ background: var(--color-success-bg);
417+ color: var(--color-success);
418+ }
419+ .sig-badge.unverified {
420+ background: var(--color-danger-bg);
421+ color: var(--color-danger);
422+ }
423+
424+ /* --- Empty state --- */
425+ .empty-state {
426+ text-align: center;
427+ padding: var(--space-16) var(--space-4);
428+ color: var(--color-text-muted);
429+ }
430+ .empty-state p {
431+ margin-bottom: var(--space-4);
432+ font-size: var(--text-lg);
433+ }
434+
435+ /* --- Breadcrumb --- */
436+ .breadcrumb {
437+ display: flex;
438+ align-items: center;
439+ flex-wrap: wrap;
440+ gap: var(--space-1);
441+ font-size: var(--text-sm);
442+ margin-bottom: var(--space-4);
443+ }
444+ .breadcrumb a {
445+ color: var(--color-link);
446+ text-decoration: none;
447+ }
448+ .breadcrumb a:hover {
449+ text-decoration: underline;
450+ }
451+ .breadcrumb-sep {
452+ color: var(--color-text-muted);
453+ }
454+ .breadcrumb-current {
455+ color: var(--color-text-muted);
456+ }
457+
458+ /* --- Issue / Patch list --- */
459+ .list-header {
460+ display: flex;
461+ align-items: center;
462+ justify-content: space-between;
463+ padding: var(--space-3) var(--space-4);
464+ background: var(--color-bg-subtle);
465+ border: 1px solid var(--color-border);
466+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
467+ gap: var(--space-4);
468+ }
469+ .list-header-tabs {
470+ display: flex;
471+ gap: var(--space-2);
472+ }
473+ .list-tab {
474+ padding: var(--space-1) var(--space-3);
475+ border-radius: var(--radius-md);
476+ font-size: var(--text-sm);
477+ text-decoration: none;
478+ color: var(--color-text-muted);
479+ }
480+ .list-tab:hover {
481+ background: var(--color-bg-inset);
482+ color: var(--color-text);
483+ }
484+ .list-tab.active {
485+ background: var(--color-bg-inset);
486+ color: var(--color-text);
487+ font-weight: 600;
488+ }
489+ .issue-list {
490+ border: 1px solid var(--color-border);
491+ border-top: none;
492+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
493+ }
494+ .issue-item {
495+ padding: var(--space-4) var(--space-4);
496+ border-bottom: 1px solid var(--color-border-muted);
497+ }
498+ .issue-item:last-child {
499+ border-bottom: none;
500+ }
501+ .issue-main {
502+ display: flex;
503+ align-items: center;
504+ gap: var(--space-2);
505+ margin-bottom: var(--space-1);
506+ flex-wrap: wrap;
507+ }
508+ .issue-title {
509+ font-weight: 500;
510+ text-decoration: none;
511+ color: var(--color-text);
512+ flex: 1;
513+ }
514+ .issue-title:hover {
515+ color: var(--color-link);
516+ }
517+ .issue-meta {
518+ font-size: var(--text-xs);
519+ color: var(--color-text-muted);
520+ display: flex;
521+ gap: var(--space-3);
522+ }
523+ .issue-status-dot,
524+ .patch-status-dot {
525+ width: 8px;
526+ height: 8px;
527+ border-radius: 50%;
528+ flex-shrink: 0;
529+ }
530+ .issue-status-dot.open,
531+ .patch-status-dot.open {
532+ background: var(--color-success);
533+ }
534+ .issue-status-dot.closed,
535+ .patch-status-dot.closed {
536+ background: var(--color-danger);
537+ }
538+ .issue-status-dot.completed {
539+ background: var(--color-merged);
540+ }
541+ .patch-status-dot.merged {
542+ background: var(--color-merged);
543+ }
544+
545+ /* --- Issue detail / Timeline --- */
546+ .issue-detail-header {
547+ display: flex;
548+ align-items: center;
549+ flex-wrap: wrap;
550+ gap: var(--space-3);
551+ margin-bottom: var(--space-4);
552+ }
553+ .issue-number {
554+ font-size: var(--text-2xl);
555+ font-weight: 600;
556+ color: var(--color-text-muted);
557+ white-space: nowrap;
558+ }
559+ .issue-detail-title {
560+ font-size: var(--text-2xl);
561+ font-weight: 600;
562+ overflow-wrap: anywhere;
563+ }
564+ .issue-detail-meta-actions {
565+ margin-left: auto;
566+ display: flex;
567+ gap: var(--space-2);
568+ flex-shrink: 0;
569+ }
570+ .patch-author-meta {
571+ display: flex;
572+ align-items: center;
573+ gap: var(--space-2);
574+ font-size: var(--text-xs);
575+ margin-top: calc(-1 * var(--space-4));
576+ margin-bottom: var(--space-6);
577+ }
578+ .patch-author-label {
579+ color: var(--color-text-muted);
580+ }
581+ .patch-author-identity {
582+ font-family: monospace;
583+ }
584+ .timeline-item {
585+ border: 1px solid var(--color-border);
586+ border-radius: var(--radius-lg);
587+ margin-bottom: var(--space-4);
588+ overflow: hidden;
589+ }
590+ .timeline-author {
591+ background: var(--color-bg-subtle);
592+ padding: var(--space-3) var(--space-4);
593+ font-size: var(--text-sm);
594+ display: flex;
595+ align-items: center;
596+ justify-content: space-between;
597+ gap: var(--space-3);
598+ border-bottom: 1px solid var(--color-border);
599+ overflow-wrap: anywhere;
600+ }
601+ .timeline-author time {
602+ color: var(--color-text-muted);
603+ font-size: var(--text-xs);
604+ width: max-content;
605+ }
606+ .timeline-author-right {
607+ margin-left: auto;
608+ display: flex;
609+ align-items: center;
610+ gap: var(--space-2);
611+ }
612+ .edited-indicator {
613+ color: var(--color-text-muted);
614+ font-size: var(--text-xs);
615+ cursor: default;
616+ }
617+
618+ /* --- Avatars --- */
619+ .avatar {
620+ display: inline-flex;
621+ border-radius: 4px;
622+ overflow: hidden;
623+ flex-shrink: 0;
624+ vertical-align: middle;
625+ }
626+ .avatar-img {
627+ width: 100%;
628+ height: 100%;
629+ object-fit: cover;
630+ display: block;
631+ color: transparent;
632+ }
633+ .avatar-placeholder {
634+ display: inline-flex;
635+ align-items: center;
636+ justify-content: center;
637+ border-radius: 4px;
638+ background: var(--color-bg-inset);
639+ color: var(--color-text-muted);
640+ font-weight: 600;
641+ flex-shrink: 0;
642+ vertical-align: middle;
643+ }
644+ .avatar-settings {
645+ display: flex;
646+ align-items: flex-start;
647+ gap: var(--space-5);
648+ }
649+ .avatar-actions {
650+ display: flex;
651+ flex-direction: column;
652+ gap: var(--space-3);
653+ }
654+ .nav-user {
655+ display: inline-flex;
656+ align-items: center;
657+ gap: var(--space-2);
658+ font-size: var(--text-sm);
659+ color: var(--color-text-muted);
660+ }
661+ .timeline-body {
662+ padding: var(--space-4);
663+ }
664+ .timeline-item-new {
665+ padding: var(--space-4);
666+ }
667+ .timeline-item-new .section-title {
668+ font-size: var(--text-base);
669+ border: none;
670+ padding-bottom: 0;
671+ }
672+ /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
673+ .timeline-author-right .inline-edit-details {
674+ border: none;
675+ }
676+ .inline-edit-details .when-open {
677+ display: none;
678+ }
679+ .inline-edit-details[open] .when-closed {
680+ display: none;
681+ }
682+ .inline-edit-details[open] .when-open {
683+ display: inline;
684+ }
685+ .inline-edit-form-area {
686+ display: none;
687+ border-top: 1px solid var(--color-border-muted);
688+ }
689+ .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
690+ display: block;
691+ }
692+ .timeline-item:has(.inline-edit-details[open]) .timeline-body {
693+ display: none;
694+ }
695+
696+ /* Title edit in issue/patch header */
697+ .issue-detail-header {
698+ align-items: flex-start;
699+ }
700+ .title-with-edit {
701+ flex: 1;
702+ min-width: 16rem;
703+ display: flex;
704+ flex-direction: column;
705+ gap: var(--space-1);
706+ }
707+ .title-edit-details {
708+ border: none;
709+ align-self: flex-start;
710+ }
711+ .title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
712+ display: none;
713+ }
714+ .title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
715+ display: none;
716+ }
717+ .title-edit-details > summary {
718+ list-style: none;
719+ }
720+
721+ .title-edit-form-area {
722+ display: none;
723+ }
724+ .title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
725+ display: flex;
726+ flex: 1;
727+ min-width: 0;
728+ }
729+ .title-edit-form {
730+ display: flex;
731+ flex-direction: column;
732+ gap: var(--space-2);
733+ flex: 1;
734+ min-width: 0;
735+ }
736+ .title-edit-form .form-input {
737+ min-width: 0;
738+ width: 100%;
739+ padding: var(--space-2) var(--space-3);
740+ border: 1px solid var(--color-border);
741+ border-radius: var(--radius-md);
742+ background: var(--color-bg);
743+ color: var(--color-text);
744+ font-size: var(--text-sm);
745+ }
746+ .title-edit-form .form-input:focus {
747+ outline: 2px solid var(--color-accent);
748+ outline-offset: -1px;
749+ border-color: var(--color-accent);
750+ }
751+ .title-edit-actions {
752+ display: flex;
753+ gap: var(--space-2);
754+ }
755+
756+ .inline-edit-form {
757+ padding: var(--space-4);
758+ display: flex;
759+ flex-direction: column;
760+ gap: var(--space-3);
761+ }
762+
763+ /* --- Reactions --- */
764+ .reaction-bar {
765+ display: flex;
766+ flex-wrap: wrap;
767+ gap: var(--space-2);
768+ padding: var(--space-2) var(--space-4);
769+ border-top: 1px solid var(--color-border-muted);
770+ }
771+ .reaction-form {
772+ display: inline;
773+ }
774+ .reaction-btn {
775+ display: inline-flex;
776+ align-items: center;
777+ gap: var(--space-1);
778+ padding: var(--space-1) var(--space-3);
779+ border: 1px solid var(--color-border);
780+ border-radius: var(--radius-full);
781+ background: var(--color-bg-subtle);
782+ color: var(--color-text);
783+ font-size: var(--text-sm);
784+ cursor: pointer;
785+ transition: background 0.1s;
786+ }
787+ .reaction-btn:hover {
788+ background: var(--color-bg-inset);
789+ }
790+ .reaction-btn.reacted {
791+ background: var(--color-accent-bg);
792+ border-color: var(--color-accent);
793+ }
794+ .reaction-picker {
795+ position: relative;
796+ }
797+ .reaction-add-btn {
798+ display: inline-flex;
799+ align-items: center;
800+ justify-content: center;
801+ width: 28px;
802+ height: 28px;
803+ border: 1px dashed var(--color-border);
804+ border-radius: var(--radius-full);
805+ background: transparent;
806+ cursor: pointer;
807+ font-size: var(--text-base);
808+ color: var(--color-text-muted);
809+ list-style: none;
810+ }
811+
812+ .reaction-picker-dropdown {
813+ position: absolute;
814+ bottom: calc(100% + var(--space-2));
815+ left: 0;
816+ background: var(--color-bg);
817+ border: 1px solid var(--color-border);
818+ border-radius: var(--radius-lg);
819+ padding: var(--space-2);
820+ display: flex;
821+ gap: var(--space-1);
822+ z-index: 10;
823+ }
824+ .reaction-picker-btn {
825+ background: none;
826+ border: none;
827+ font-size: 1.25rem;
828+ cursor: pointer;
829+ padding: var(--space-1);
830+ border-radius: var(--radius-sm);
831+ }
832+ .reaction-picker-btn:hover {
833+ background: var(--color-bg-inset);
834+ }
835+
836+ /* --- Patch subview tabs --- */
837+ .patch-tab-nav {
838+ display: flex;
839+ border-bottom: 1px solid var(--color-border);
840+ margin-bottom: var(--space-6);
841+ }
842+ .tab-count {
843+ display: inline-flex;
844+ align-items: center;
845+ justify-content: center;
846+ min-width: 1.4em;
847+ padding: 0 var(--space-1);
848+ margin-left: var(--space-1);
849+ font-size: var(--text-xs);
850+ font-weight: 500;
851+ background: var(--color-bg-inset);
852+ border-radius: 999px;
853+ color: var(--color-text-muted);
854+ }
855+
856+ /* --- Patch apply status --- */
857+ .apply-status {
858+ margin: var(--space-4) 0;
859+ }
860+ .apply-result {
861+ display: flex;
862+ align-items: flex-start;
863+ gap: var(--space-2);
864+ padding: var(--space-3) var(--space-4);
865+ border-radius: var(--radius-md);
866+ font-size: var(--text-sm);
867+ flex-direction: column;
868+ }
869+ .apply-result > div {
870+ display: flex;
871+ gap: var(--space-2);
872+ align-items: center;
873+ }
874+ .apply-clean {
875+ background: var(--color-success-bg);
876+ color: var(--color-success);
877+ border: 1px solid var(--color-success);
878+ }
879+ .apply-conflict {
880+ background: var(--color-danger-bg);
881+ color: var(--color-danger);
882+ border: 1px solid var(--color-danger);
883+ }
884+ .apply-checking,
885+ .apply-unknown {
886+ color: var(--color-text-muted);
887+ font-style: italic;
888+ font-size: var(--text-sm);
889+ }
890+ .apply-output {
891+ font-family: var(--font-mono);
892+ font-size: var(--text-xs);
893+ white-space: pre-wrap;
894+ margin-top: var(--space-2);
895+ padding: var(--space-2);
896+ background: rgba(0, 0, 0, 0.05);
897+ border-radius: var(--radius-sm);
898+ width: 100%;
899+ }
900+ .patch-actions {
901+ display: flex;
902+ gap: var(--space-3);
903+ margin: var(--space-4) 0;
904+ flex-wrap: wrap;
905+ }
906+ .patch-card-title-row {
907+ display: flex;
908+ align-items: flex-start;
909+ gap: var(--space-3);
910+ justify-content: space-between;
911+ margin-bottom: var(--space-3);
912+ }
913+ .patch-card-title-row .commit-card-subject {
914+ margin-bottom: 0;
915+ }
916+ .patch-card-description {
917+ margin-top: var(--space-4);
918+ padding-top: var(--space-4);
919+ border-top: 1px solid var(--color-border-muted);
920+ }
921+
922+ /* --- Repo home layout --- */
923+ .repo-home-layout {
924+ display: grid;
925+ gap: var(--space-8);
926+ }
927+ @media (min-width: 768px) {
928+ .repo-home-layout {
929+ grid-template-columns: 1fr 1fr;
930+ }
931+ }
932+ .readme-section {
933+ border: 1px solid var(--color-border);
934+ border-radius: var(--radius-lg);
935+ overflow: hidden;
936+ }
937+ .readme-header {
938+ background: var(--color-bg-subtle);
939+ padding: var(--space-3) var(--space-4);
940+ font-size: var(--text-sm);
941+ font-weight: 500;
942+ border-bottom: 1px solid var(--color-border);
943+ display: flex;
944+ align-items: center;
945+ justify-content: space-between;
946+ }
947+
948+ /* --- Markdown body --- */
949+ .markdown-body {
950+ font-size: var(--text-sm);
951+ line-height: 1.6;
952+ color: var(--color-text);
953+ }
954+ .markdown-body h1,
955+ .markdown-body h2,
956+ .markdown-body h3,
957+ .markdown-body h4,
958+ .markdown-body h5,
959+ .markdown-body h6 {
960+ margin-top: var(--space-6);
961+ margin-bottom: var(--space-3);
962+ }
963+ .markdown-body p {
964+ margin-bottom: var(--space-4);
965+ }
966+ .markdown-body a {
967+ color: var(--color-link);
968+ }
969+ .markdown-body code {
970+ background: var(--color-bg-inset);
971+ padding: 0.1em 0.3em;
972+ border-radius: var(--radius-sm);
973+ font-size: 0.9em;
974+ }
975+ .markdown-body pre {
976+ background: var(--color-bg-subtle);
977+ border: 1px solid var(--color-border);
978+ border-radius: var(--radius-md);
979+ padding: var(--space-4);
980+ overflow: auto;
981+ margin-bottom: var(--space-4);
982+ }
983+ .markdown-body pre code {
984+ background: none;
985+ padding: 0;
986+ }
987+ .markdown-body ul,
988+ .markdown-body ol {
989+ padding-left: var(--space-6);
990+ margin-bottom: var(--space-4);
991+ }
992+ .markdown-body ul {
993+ list-style: disc;
994+ }
995+ .markdown-body ol {
996+ list-style: decimal;
997+ }
998+ .markdown-body li {
999+ margin-bottom: var(--space-1);
1000+ }
1001+ .markdown-body blockquote {
1002+ border-left: 3px solid var(--color-border);
1003+ padding-left: var(--space-4);
1004+ color: var(--color-text-muted);
1005+ margin: var(--space-4) 0;
1006+ }
1007+ .markdown-body table {
1008+ width: 100%;
1009+ border-collapse: collapse;
1010+ margin-bottom: var(--space-4);
1011+ }
1012+ .markdown-body th,
1013+ .markdown-body td {
1014+ padding: var(--space-2) var(--space-3);
1015+ border: 1px solid var(--color-border);
1016+ }
1017+ .markdown-body th {
1018+ background: var(--color-bg-subtle);
1019+ font-weight: 600;
1020+ }
1021+ .markdown-body hr {
1022+ border: none;
1023+ border-top: 1px solid var(--color-border);
1024+ margin: var(--space-6) 0;
1025+ }
1026+ .markdown-body img {
1027+ max-width: 100%;
1028+ border-radius: var(--radius-md);
1029+ }
1030+ .markdown-body details {
1031+ margin-bottom: var(--space-4);
1032+ }
1033+ .markdown-body summary {
1034+ cursor: pointer;
1035+ font-weight: 500;
1036+ }
1037+ .readme-section .markdown-body,
1038+ .file-blob-body .markdown-body {
1039+ padding: var(--space-4);
1040+ }
1041+
1042+ /* --- Code setup block --- */
1043+ .code-setup {
1044+ background: var(--color-bg-subtle);
1045+ border: 1px solid var(--color-border);
1046+ border-radius: var(--radius-lg);
1047+ padding: var(--space-4);
1048+ font-size: var(--text-sm);
1049+ margin-top: var(--space-4);
1050+ overflow: auto;
1051+ text-align: left;
1052+ }
1053+ .code-plain {
1054+ background: var(--color-bg-subtle);
1055+ padding: var(--space-4);
1056+ overflow: auto;
1057+ font-size: var(--text-sm);
1058+ line-height: 1.6;
1059+ }
1060+
1061+ /* --- Search row (search form + sort select) --- */
1062+ .search-row {
1063+ display: flex;
1064+ align-items: center;
1065+ gap: var(--space-3);
1066+ margin-bottom: var(--space-5);
1067+ }
1068+ .search-form {
1069+ flex: 1;
1070+ }
1071+ .repo-sort-select {
1072+ padding: var(--space-1) var(--space-2);
1073+ padding-right: var(--space-5);
1074+ border: 1px solid var(--color-border);
1075+ border-radius: var(--radius-md);
1076+ background: var(--color-bg-subtle);
1077+ font-size: var(--text-sm);
1078+ color: var(--color-text);
1079+ cursor: pointer;
1080+ appearance: auto;
1081+ }
1082+ .repo-sort-select:hover {
1083+ border-color: var(--color-text-muted);
1084+ }
1085+ .repo-sort-select:focus {
1086+ outline: 2px solid var(--color-accent);
1087+ outline-offset: -1px;
1088+ }
1089+ .search-input-wrap {
1090+ display: flex;
1091+ gap: var(--space-2);
1092+ }
1093+ .search-input {
1094+ flex: 1;
1095+ padding: var(--space-2) var(--space-3);
1096+ border: 1px solid var(--color-border);
1097+ border-radius: var(--radius-md);
1098+ background: var(--color-bg);
1099+ color: var(--color-text);
1100+ font-size: var(--text-sm);
1101+ }
1102+ .search-input:focus {
1103+ outline: 2px solid var(--color-accent);
1104+ outline-offset: -1px;
1105+ border-color: var(--color-accent);
1106+ }
1107+
1108+ /* --- Issue close bar --- */
1109+ .issue-close-bar {
1110+ display: flex;
1111+ justify-content: flex-end;
1112+ padding: var(--space-3) 0;
1113+ border-top: 1px solid var(--color-border-muted);
1114+ margin-top: var(--space-2);
1115+ }
1116+
1117+ /* --- Auth divider --- */
1118+ .auth-divider {
1119+ display: flex;
1120+ align-items: center;
1121+ gap: var(--space-3);
1122+ margin: var(--space-4) 0;
1123+ color: var(--color-text-muted);
1124+ font-size: var(--text-sm);
1125+ }
1126+ .auth-divider::before,
1127+ .auth-divider::after {
1128+ content: "";
1129+ flex: 1;
1130+ height: 1px;
1131+ background: var(--color-border);
1132+ }
1133+
1134+ /* --- Passkey UI in settings --- */
1135+ .passkey-actions {
1136+ display: flex;
1137+ flex-direction: column;
1138+ gap: var(--space-3);
1139+ align-items: flex-start;
1140+ margin-top: var(--space-3);
1141+ }
1142+ .passkey-status {
1143+ font-size: var(--text-sm);
1144+ }
1145+
1146+ /* --- Danger zone --- */
1147+ .danger-zone {
1148+ margin-top: var(--space-8);
1149+ }
1150+ .danger-title {
1151+ color: var(--color-danger);
1152+ border-bottom-color: var(--color-danger);
1153+ }
1154+ .danger-card {
1155+ border-color: var(--color-danger);
1156+ background: var(--color-danger-bg);
1157+ }
1158+ .danger-item {
1159+ display: flex;
1160+ align-items: center;
1161+ justify-content: space-between;
1162+ gap: var(--space-4);
1163+ flex-wrap: wrap;
1164+ }
1165+ .danger-item p {
1166+ margin: var(--space-1) 0 0;
1167+ }
1168+ .btn-danger {
1169+ background: var(--color-danger);
1170+ border-color: transparent;
1171+ color: #fff;
1172+ }
1173+ .btn-danger:hover {
1174+ background: color-mix(in srgb, var(--color-danger) 85%, #000);
1175+ }
1176+
1177+ /* --- Release list items --- */
1178+ .release-item-header {
1179+ display: flex;
1180+ gap: var(--space-3);
1181+ align-items: flex-start;
1182+ }
1183+ .release-item-main {
1184+ flex: 1;
1185+ min-width: 0;
1186+ }
1187+ .release-item-title {
1188+ font-size: var(--text-lg);
1189+ font-weight: 700;
1190+ text-decoration: none;
1191+ color: var(--color-text);
1192+ display: block;
1193+ margin-bottom: var(--space-1);
1194+ }
1195+ .release-item-title:hover {
1196+ color: var(--color-link);
1197+ }
1198+ .release-item-meta {
1199+ display: flex;
1200+ flex-wrap: wrap;
1201+ gap: var(--space-3);
1202+ align-items: center;
1203+ font-size: var(--text-xs);
1204+ color: var(--color-text-muted);
1205+ }
1206+ .release-item-date {
1207+ display: flex;
1208+ flex-direction: column;
1209+ align-items: flex-end;
1210+ gap: var(--space-2);
1211+ font-size: var(--text-xs);
1212+ color: var(--color-text-muted);
1213+ white-space: nowrap;
1214+ flex-shrink: 0;
1215+ }
1216+
1217+ /* --- Release notes preview in list --- */
1218+ .release-notes-section {
1219+ margin-top: var(--space-3);
1220+ }
1221+ .release-notes-label {
1222+ font-size: var(--text-xs);
1223+ font-weight: 600;
1224+ color: var(--color-text-muted);
1225+ text-transform: uppercase;
1226+ letter-spacing: 0.05em;
1227+ margin-bottom: var(--space-2);
1228+ }
1229+ .notes-expand > summary {
1230+ list-style: none;
1231+ cursor: pointer;
1232+ outline: none;
1233+ }
1234+
1235+ .notes-preview {
1236+ max-height: calc(5 * 1.6em);
1237+ overflow: hidden;
1238+ position: relative;
1239+ white-space: pre-wrap;
1240+ font-size: var(--text-sm);
1241+ color: var(--color-text);
1242+ line-height: 1.6;
1243+ word-break: break-word;
1244+ margin-bottom: var(--space-1);
1245+ }
1246+ .notes-preview::after {
1247+ content: "";
1248+ position: absolute;
1249+ bottom: 0;
1250+ left: 0;
1251+ right: 0;
1252+ height: 2.5em;
1253+ background: linear-gradient(transparent, var(--color-bg));
1254+ pointer-events: none;
1255+ }
1256+ .notes-expand[open] .notes-preview {
1257+ display: none;
1258+ }
1259+ .notes-full {
1260+ padding-top: var(--space-3);
1261+ padding-bottom: var(--space-1);
1262+ }
1263+ .notes-expand[open] {
1264+ display: flex;
1265+ flex-direction: column;
1266+ }
1267+ .notes-expand[open] > summary {
1268+ order: 1;
1269+ }
1270+ .notes-expand[open] > .notes-full {
1271+ order: 0;
1272+ }
1273+ .notes-toggle-label {
1274+ display: inline-block;
1275+ font-size: var(--text-xs);
1276+ color: var(--color-link);
1277+ margin-top: var(--space-1);
1278+ }
1279+ .notes-toggle-label:hover {
1280+ text-decoration: underline;
1281+ }
1282+ .notes-toggle-label::before {
1283+ content: "Show more ↓";
1284+ }
1285+ .notes-expand[open] .notes-toggle-label::before {
1286+ content: "Show less ↑";
1287+ }
1288+
1289+ /* --- Release detail --- */
1290+ .release-detail {
1291+ padding: var(--space-6) 0;
1292+ }
1293+ .release-header {
1294+ margin-bottom: var(--space-6);
1295+ }
1296+ .release-notes {
1297+ margin-bottom: var(--space-6);
1298+ }
1299+ .release-assets {
1300+ margin-bottom: var(--space-6);
1301+ }
1302+ .release-assets-heading {
1303+ font-size: var(--text-base);
1304+ font-weight: 600;
1305+ margin-bottom: var(--space-3);
1306+ }
1307+ .asset-list {
1308+ border: 1px solid var(--color-border);
1309+ border-radius: var(--radius-lg);
1310+ overflow: hidden;
1311+ }
1312+ .asset-item {
1313+ display: flex;
1314+ align-items: center;
1315+ gap: var(--space-4);
1316+ padding: var(--space-3) var(--space-4);
1317+ border-bottom: 1px solid var(--color-border-muted);
1318+ font-size: var(--text-sm);
1319+ }
1320+ .asset-item:last-child {
1321+ border-bottom: none;
1322+ }
1323+ .asset-name {
1324+ flex: 1;
1325+ color: var(--color-link);
1326+ text-decoration: none;
1327+ font-family: var(--font-mono);
1328+ font-size: var(--text-xs);
1329+ }
1330+ .asset-name:hover {
1331+ text-decoration: underline;
1332+ }
1333+ .asset-name-pending {
1334+ color: var(--color-text-muted);
1335+ cursor: default;
1336+ }
1337+ .asset-item-pending {
1338+ opacity: 0.6;
1339+ }
1340+ .asset-size,
1341+ .asset-meta {
1342+ color: var(--color-text-muted);
1343+ font-size: var(--text-xs);
1344+ white-space: nowrap;
1345+ }
1346+ .release-back {
1347+ margin-top: var(--space-4);
1348+ display: flex;
1349+ gap: var(--space-3);
1350+ align-items: center;
1351+ justify-content: space-between;
1352+ }
1353+
1354+ /* ============================================================
1355+ Pagination
1356+ ============================================================ */
1357+ .commit-verify-hint {
1358+ margin-bottom: var(--space-4);
1359+ font-size: var(--text-xs);
1360+ color: var(--color-text-muted);
1361+ }
1362+ .commit-verify-hint code {
1363+ word-break: break-all;
1364+ }
1365+ .commit-cursor-nav {
1366+ display: flex;
1367+ justify-content: space-between;
1368+ border-top: 1px solid var(--color-border);
1369+ margin-top: var(--space-2);
1370+ padding: var(--space-4) 0 var(--space-2);
1371+ }
1372+
1373+ .commit-cursor-prev {
1374+ flex: 1;
1375+ }
1376+ .commit-cursor-next {
1377+ flex: 1;
1378+ text-align: right;
1379+ }
1380+
1381+ .pagination {
1382+ display: grid;
1383+ grid-template-columns: 1fr auto 1fr;
1384+ align-items: center;
1385+ border-top: 1px solid var(--color-border);
1386+ margin-top: var(--space-2);
1387+ padding: var(--space-4) 0 var(--space-2);
1388+ }
1389+
1390+ .pagination-prev {
1391+ justify-self: start;
1392+ }
1393+ .pagination-next {
1394+ justify-self: end;
1395+ }
1396+
1397+ .pagination-info {
1398+ justify-self: center;
1399+ font-size: var(--text-sm);
1400+ color: var(--color-text-muted);
1401+ }
1402+
1403+ .pagination-btn {
1404+ display: inline-flex;
1405+ align-items: center;
1406+ gap: var(--space-1);
1407+ padding: var(--space-1) var(--space-3);
1408+ border: 1px solid var(--color-border);
1409+ border-radius: var(--radius-md);
1410+ font-size: var(--text-sm);
1411+ font-weight: 500;
1412+ text-decoration: none;
1413+ color: var(--color-text);
1414+ background: var(--color-bg-subtle);
1415+ transition:
1416+ background 0.1s,
1417+ border-color 0.1s;
1418+ white-space: nowrap;
1419+ }
1420+
1421+ .pagination-btn:hover {
1422+ background: var(--color-bg-inset);
1423+ border-color: var(--color-border);
1424+ color: var(--color-text);
1425+ text-decoration: none;
1426+ }
1427+
1428+ /* Settings */
1429+ .theme-options {
1430+ display: flex;
1431+ gap: var(--space-4);
1432+ margin-bottom: var(--space-4);
1433+ flex-wrap: wrap;
1434+ }
1435+ .theme-option {
1436+ display: flex;
1437+ align-items: center;
1438+ gap: var(--space-2);
1439+ cursor: pointer;
1440+ font-size: var(--text-sm);
1441+ }
1442+ .settings-form {
1443+ display: flex;
1444+ flex-direction: column;
1445+ gap: var(--space-4);
1446+ }
1447+ .passkey-list {
1448+ display: flex;
1449+ flex-direction: column;
1450+ gap: var(--space-2);
1451+ margin-bottom: var(--space-4);
1452+ }
1453+ .passkey-item {
1454+ display: flex;
1455+ align-items: center;
1456+ gap: var(--space-3);
1457+ padding: var(--space-2) 0;
1458+ border-bottom: 1px solid var(--color-border-muted);
1459+ font-size: var(--text-sm);
1460+ }
1461+ .passkey-item:last-child {
1462+ border-bottom: none;
1463+ }
1464+ .passkey-date {
1465+ flex: 1;
1466+ color: var(--color-text-muted);
1467+ }
1468+ .queue-bulk-actions {
1469+ display: flex;
1470+ gap: var(--space-2);
1471+ margin-bottom: var(--space-4);
1472+ }
1473+ .queue-list {
1474+ list-style: none;
1475+ padding: 0;
1476+ margin: 0;
1477+ max-height: 32rem;
1478+ overflow-y: auto;
1479+ }
1480+ .queue-item {
1481+ display: flex;
1482+ align-items: flex-start;
1483+ justify-content: space-between;
1484+ gap: var(--space-4);
1485+ padding: var(--space-3) 0;
1486+ border-top: 1px solid var(--color-border-muted);
1487+ font-size: var(--text-sm);
1488+ }
1489+ .queue-item-meta {
1490+ display: flex;
1491+ flex-direction: column;
1492+ gap: var(--space-1);
1493+ min-width: 0;
1494+ }
1495+ .queue-item-header {
1496+ display: flex;
1497+ align-items: baseline;
1498+ gap: var(--space-2);
1499+ }
1500+ .queue-item-date {
1501+ color: var(--color-text-muted);
1502+ font-size: var(--text-xs);
1503+ }
1504+ .queue-item-answer {
1505+ margin: 0;
1506+ color: var(--color-text-muted);
1507+ font-size: var(--text-xs);
1508+ white-space: pre-wrap;
1509+ word-break: break-word;
1510+ }
1511+ .queue-item-actions {
1512+ display: flex;
1513+ gap: var(--space-2);
1514+ flex-shrink: 0;
1515+ }
1516+ .ssh-key-info {
1517+ flex: 1;
1518+ display: flex;
1519+ flex-direction: column;
1520+ gap: var(--space-1);
1521+ min-width: 0;
1522+ }
1523+ .ssh-key-name {
1524+ font-weight: 500;
1525+ }
1526+ .ssh-key-fingerprint {
1527+ font-family: var(--font-mono);
1528+ font-size: var(--text-xs);
1529+ overflow: hidden;
1530+ text-overflow: ellipsis;
1531+ white-space: nowrap;
1532+ }
1533+ .form-textarea {
1534+ resize: vertical;
1535+ min-height: 4.5rem;
1536+ font-family: var(--font-mono);
1537+ font-size: var(--text-sm);
1538+ }
1539+
1540+ /* Clone popup */
1541+ .clone-popup-details {
1542+ position: relative;
1543+ display: inline-block;
1544+ }
1545+ .clone-popup-details > summary {
1546+ list-style: none;
1547+ }
1548+ .timeline-author-right .inline-edit-details > summary {
1549+ list-style: none;
1550+ }
1551+ .clone-popup {
1552+ position: absolute;
1553+ right: 1rem;
1554+ top: calc(100% + var(--space-2));
1555+ background: var(--color-bg);
1556+ border: 1px solid var(--color-border);
1557+ border-radius: var(--radius-lg);
1558+ padding: var(--space-3);
1559+ min-width: 22rem;
1560+ z-index: 100;
1561+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
1562+ display: flex;
1563+ flex-direction: column;
1564+ gap: var(--space-2);
1565+ }
1566+ .clone-url-row {
1567+ display: flex;
1568+ align-items: center;
1569+ gap: var(--space-2);
1570+ }
1571+ .clone-url-label {
1572+ font-size: var(--text-xs);
1573+ font-weight: 600;
1574+ color: var(--color-text-muted);
1575+ text-transform: uppercase;
1576+ letter-spacing: 0.05em;
1577+ min-width: 2.75rem;
1578+ }
1579+ .clone-url-input {
1580+ flex: 1;
1581+ font-family: var(--font-mono);
1582+ font-size: var(--text-sm);
1583+ background: var(--color-bg-subtle);
1584+ border: 1px solid var(--color-border-muted);
1585+ border-radius: var(--radius-md);
1586+ padding: var(--space-1) var(--space-2);
1587+ color: var(--color-text);
1588+ cursor: text;
1589+ min-width: 0;
1590+ }
1591+
1592+ /* User list */
1593+ .user-list {
1594+ display: flex;
1595+ flex-direction: column;
1596+ gap: var(--space-2);
1597+ margin-bottom: var(--space-6);
1598+ }
1599+ .user-item {
1600+ display: flex;
1601+ align-items: center;
1602+ gap: var(--space-3);
1603+ padding: var(--space-2) 0;
1604+ border-bottom: 1px solid var(--color-border-muted);
1605+ font-size: var(--text-sm);
1606+ }
1607+ .user-item:last-child {
1608+ border-bottom: none;
1609+ }
1610+ .user-name {
1611+ font-weight: 500;
1612+ flex: 1;
1613+ }
1614+ .user-date {
1615+ color: var(--color-text-muted);
1616+ font-size: var(--text-xs);
1617+ }
1618+
1619+ /* Labels */
1620+ .label-badge {
1621+ display: inline-block;
1622+ padding: 0 var(--space-2);
1623+ border-radius: 2em;
1624+ font-size: var(--text-xs);
1625+ font-weight: 500;
1626+ line-height: 1.6;
1627+ word-break: break-all;
1628+ }
1629+ .issue-labels {
1630+ display: flex;
1631+ flex-wrap: wrap;
1632+ gap: var(--space-1);
1633+ margin-bottom: var(--space-2);
1634+ }
1635+ .issue-labels-row {
1636+ display: flex;
1637+ flex-wrap: wrap;
1638+ align-items: center;
1639+ gap: var(--space-2);
1640+ padding: var(--space-2) 0 var(--space-3);
1641+ }
1642+ .label-badge-wrap {
1643+ display: inline-flex;
1644+ align-items: center;
1645+ gap: var(--space-1);
1646+ }
1647+ .label-remove-form {
1648+ display: inline;
1649+ }
1650+ .label-remove-btn {
1651+ background: none;
1652+ border: none;
1653+ cursor: pointer;
1654+ padding: 0 2px;
1655+ font-size: var(--text-sm);
1656+ line-height: 1;
1657+ color: var(--color-text-muted);
1658+ opacity: 0.7;
1659+ }
1660+ .label-remove-btn:hover {
1661+ opacity: 1;
1662+ }
1663+ .label-add-inline-form {
1664+ display: inline-flex;
1665+ align-items: center;
1666+ gap: var(--space-2);
1667+ }
1668+ .label-select {
1669+ font-size: var(--text-sm);
1670+ padding: var(--space-1) var(--space-2);
1671+ border: 1px solid var(--color-border);
1672+ border-radius: var(--radius-md);
1673+ background: var(--color-bg);
1674+ color: var(--color-text);
1675+ height: 2rem;
1676+ }
1677+ .label-checkbox-list {
1678+ display: flex;
1679+ flex-wrap: wrap;
1680+ gap: var(--space-2);
1681+ margin-top: var(--space-1);
1682+ }
1683+ .label-checkbox-item {
1684+ display: inline-flex;
1685+ align-items: center;
1686+ gap: var(--space-1);
1687+ cursor: pointer;
1688+ }
1689+ .label-settings-list {
1690+ display: flex;
1691+ flex-direction: column;
1692+ margin-bottom: var(--space-4);
1693+ max-height: 24rem;
1694+ overflow-y: auto;
1695+ }
1696+ .label-settings-item {
1697+ display: flex;
1698+ align-items: center;
1699+ gap: var(--space-3);
1700+ padding: var(--space-2) 0;
1701+ border-bottom: 1px solid var(--color-border-muted);
1702+ font-size: var(--text-sm);
1703+ }
1704+ .label-settings-item:last-child {
1705+ border-bottom: none;
1706+ }
1707+ .label-settings-swatch {
1708+ width: 12px;
1709+ height: 12px;
1710+ border-radius: 50%;
1711+ flex-shrink: 0;
1712+ border: 1px solid rgba(0, 0, 0, 0.15);
1713+ }
1714+ .label-settings-name {
1715+ flex: 1;
1716+ font-weight: 500;
1717+ }
1718+ .label-add-form {
1719+ display: flex;
1720+ align-items: center;
1721+ gap: var(--space-2);
1722+ }
1723+ .label-name-input {
1724+ flex: 1;
1725+ min-width: 0;
1726+ padding: var(--space-2) var(--space-3);
1727+ border: 1px solid var(--color-border);
1728+ border-radius: var(--radius-md);
1729+ background: var(--color-bg);
1730+ color: var(--color-text);
1731+ font-size: var(--text-sm);
1732+ height: 2rem;
1733+ box-sizing: border-box;
1734+ }
1735+ .label-name-input:focus {
1736+ outline: 2px solid var(--color-accent);
1737+ outline-offset: -1px;
1738+ border-color: var(--color-accent);
1739+ }
1740+ .label-color-swatch-label {
1741+ flex-shrink: 0;
1742+ cursor: pointer;
1743+ display: block;
1744+ line-height: 0;
1745+ }
1746+ .label-color-input {
1747+ display: block;
1748+ width: 2rem;
1749+ height: 2rem;
1750+ cursor: pointer;
1751+ appearance: none;
1752+ box-sizing: border-box;
1753+ clip-path: circle(50%);
1754+ border: none;
1755+ }
1756+
1757+ /* Label filter (details/summary popup) */
1758+ .list-header-actions {
1759+ display: flex;
1760+ align-items: center;
1761+ gap: var(--space-2);
1762+ flex-shrink: 0;
1763+ }
1764+ .label-filter {
1765+ position: relative;
1766+ }
1767+ .label-filter-popup {
1768+ position: absolute;
1769+ right: 0;
1770+ top: calc(100% + var(--space-1));
1771+ z-index: 10;
1772+ background: var(--color-bg);
1773+ border: 1px solid var(--color-border);
1774+ border-radius: var(--radius-lg);
1775+ padding: var(--space-3);
1776+ min-width: 180px;
1777+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1778+ }
1779+ .label-filter-list {
1780+ display: flex;
1781+ flex-direction: column;
1782+ gap: var(--space-2);
1783+ margin-bottom: var(--space-3);
1784+ max-height: 16rem;
1785+ overflow-y: auto;
1786+ }
1787+ .label-filter-item {
1788+ display: flex;
1789+ align-items: center;
1790+ gap: var(--space-2);
1791+ cursor: pointer;
1792+ font-size: var(--text-sm);
1793+ }
1794+ .label-filter-actions {
1795+ display: flex;
1796+ gap: var(--space-2);
1797+ }
1798+}
Msrc/styles/main.css
@@ -1,2905 +1,7 @@
1-/* View Transitions */
2-@view-transition {
3- navigation: auto;
4-}
1+/* Declare layer order explicitly */
2+@layer reset, tokens, layout, components, utilities;
53
6-.site-nav {
7- view-transition-name: site-nav;
8-}
9-.repo-header {
10- view-transition-name: repo-header;
11-}
12-.breadcrumb {
13- view-transition-name: breadcrumb;
14-}
15-
16-/* ============================================================
17- @layer reset
18- ============================================================ */
19-@layer reset {
20- *,
21- *::before,
22- *::after {
23- box-sizing: border-box;
24- margin: 0;
25- padding: 0;
26- }
27- html {
28- -webkit-text-size-adjust: 100%;
29- }
30- body {
31- min-height: 100dvh;
32- min-width: 500px;
33- }
34- img,
35- video {
36- max-width: 100%;
37- height: auto;
38- }
39- button,
40- input,
41- textarea,
42- select {
43- font: inherit;
44- }
45- a {
46- color: inherit;
47- }
48- ul,
49- ol {
50- list-style: none;
51- }
52- h1,
53- h2,
54- h3,
55- h4,
56- h5,
57- h6 {
58- font-weight: 600;
59- line-height: 1.2;
60- }
61- pre,
62- code {
63- font-family: var(--font-mono);
64- }
65-}
66-
67-/* ============================================================
68- @layer tokens
69- ============================================================ */
70-@layer tokens {
71- :root {
72- /* Colors — light */
73- --color-bg: #ffffff;
74- --color-bg-subtle: #f6f8fa;
75- --color-bg-inset: #eef0f2;
76- --color-border: #d0d7de;
77- --color-border-muted: #e4e7eb;
78- --color-text: #1f2328;
79- --color-text-muted: #656d76;
80- --color-link: #0969da;
81- --color-link-hover: #0550ae;
82-
83- --color-accent: #fd7e14;
84- --color-accent-bg: #fff3cd;
85-
86- --color-success: #1a7f37;
87- --color-success-bg: #dafbe1;
88- --color-danger: #cf222e;
89- --color-danger-bg: #ffebe9;
90- --color-warning: #9a6700;
91- --color-warning-bg: #fff8c5;
92- --color-merged: #8250df;
93- --color-merged-bg: #fbefff;
94-
95- /* Typography */
96- --font-sans:
97- system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
98- --font-mono:
99- ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
100-
101- --text-xs: 0.75rem;
102- --text-sm: 0.875rem;
103- --text-base: 1rem;
104- --text-lg: 1.125rem;
105- --text-xl: 1.25rem;
106- --text-2xl: 1.5rem;
107- --text-3xl: 1.875rem;
108-
109- /* Spacing */
110- --space-1: 0.25rem;
111- --space-2: 0.5rem;
112- --space-3: 0.75rem;
113- --space-4: 1rem;
114- --space-5: 1.25rem;
115- --space-6: 1.5rem;
116- --space-8: 2rem;
117- --space-10: 2.5rem;
118- --space-12: 3rem;
119- --space-16: 4rem;
120-
121- /* Border radius */
122- --radius-sm: 4px;
123- --radius-md: 6px;
124- --radius-lg: 8px;
125- --radius-full: 9999px;
126-
127- /* Shadows */
128- --shadow-sm: 0 1px 0 rgba(27, 31, 36, 0.04);
129- --shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
130- --shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2);
131- }
132-
133- @media (prefers-color-scheme: dark) {
134- :root {
135- --color-bg: #0d1117;
136- --color-bg-subtle: #161b22;
137- --color-bg-inset: #1c2128;
138- --color-border: #30363d;
139- --color-border-muted: #21262d;
140- --color-text: #e6edf3;
141- --color-text-muted: #7d8590;
142- --color-link: #58a6ff;
143- --color-link-hover: #79c0ff;
144-
145- --color-accent: #f0883e;
146- --color-accent-bg: #3d2f00;
147-
148- --color-success: #3fb950;
149- --color-success-bg: #0d2b19;
150- --color-danger: #f85149;
151- --color-danger-bg: #2d1419;
152- --color-warning: #d29922;
153- --color-warning-bg: #2c2000;
154- --color-merged: #bc8cff;
155- --color-merged-bg: #1e0b3e;
156- }
157- }
158-
159- /* Explicit theme overrides — applied via data-theme attribute set by cookie */
160- :root[data-theme="dark"] {
161- --color-bg: #0d1117;
162- --color-bg-subtle: #161b22;
163- --color-bg-inset: #1c2128;
164- --color-border: #30363d;
165- --color-border-muted: #21262d;
166- --color-text: #e6edf3;
167- --color-text-muted: #7d8590;
168- --color-link: #58a6ff;
169- --color-link-hover: #79c0ff;
170- --color-accent: #f0883e;
171- --color-accent-bg: #3d2f00;
172- --color-success: #3fb950;
173- --color-success-bg: #0d2b19;
174- --color-danger: #f85149;
175- --color-danger-bg: #2d1419;
176- --color-warning: #d29922;
177- --color-warning-bg: #2c2000;
178- --color-merged: #bc8cff;
179- --color-merged-bg: #1e0b3e;
180- }
181- :root[data-theme="light"] {
182- --color-bg: #ffffff;
183- --color-bg-subtle: #f6f8fa;
184- --color-bg-inset: #eef0f2;
185- --color-border: #d0d7de;
186- --color-border-muted: #e4e7eb;
187- --color-text: #1f2328;
188- --color-text-muted: #656d76;
189- --color-link: #0969da;
190- --color-link-hover: #0550ae;
191- --color-accent: #fd7e14;
192- --color-accent-bg: #fff3cd;
193- --color-success: #1a7f37;
194- --color-success-bg: #dafbe1;
195- --color-danger: #cf222e;
196- --color-danger-bg: #ffebe9;
197- --color-warning: #9a6700;
198- --color-warning-bg: #fff8c5;
199- --color-merged: #8250df;
200- --color-merged-bg: #fbefff;
201- }
202-}
203-
204-/* ============================================================
205- @layer layout
206- ============================================================ */
207-@layer layout {
208- body {
209- font-family: var(--font-sans);
210- font-size: var(--text-base);
211- color: var(--color-text);
212- background: var(--color-bg);
213- display: grid;
214- grid-template-rows: auto 1fr auto;
215- min-height: 100dvh;
216- }
217-
218- .site-header {
219- background: var(--color-bg-subtle);
220- border-bottom: 1px solid var(--color-border);
221- position: sticky;
222- top: 0;
223- z-index: 100;
224- }
225-
226- .site-nav {
227- max-width: 1200px;
228- margin: 0 auto;
229- padding: var(--space-3) var(--space-4);
230- display: flex;
231- align-items: center;
232- gap: var(--space-4);
233- }
234-
235- .site-logo {
236- display: flex;
237- align-items: center;
238- gap: var(--space-2);
239- text-decoration: none;
240- font-weight: 700;
241- font-size: var(--text-lg);
242- color: var(--color-text);
243- }
244-
245- .logo-icon {
246- width: 1.6em;
247- height: 1.6em;
248- display: block;
249- flex-shrink: 0;
250- }
251-
252- .nav-links {
253- margin-left: auto;
254- display: flex;
255- align-items: center;
256- gap: var(--space-3);
257- }
258-
259- .nav-user {
260- font-size: var(--text-sm);
261- color: var(--color-text-muted);
262- }
263-
264- .site-main {
265- padding: var(--space-8) var(--space-4);
266- min-width: 0;
267- }
268-
269- .site-footer {
270- text-align: center;
271- padding: var(--space-6) var(--space-4);
272- font-size: var(--text-sm);
273- color: var(--color-text-muted);
274- border-top: 1px solid var(--color-border);
275- }
276-
277- .container {
278- max-width: 1000px;
279- margin: 0 auto;
280- overflow-x: clip;
281- }
282-
283- .container-narrow {
284- max-width: 680px;
285- }
286-
287- .inline-form {
288- display: inline;
289- }
290-
291- /* --- Two-step delete confirmation --- */
292- .confirm-details {
293- position: relative;
294- display: inline-block;
295- }
296- .confirm-details > summary {
297- list-style: none;
298- }
299- .confirm-details > summary::-webkit-details-marker {
300- display: none;
301- }
302- .confirm-popup {
303- position: absolute;
304- right: 0;
305- top: calc(100% + var(--space-1));
306- background: var(--color-bg);
307- border: 1px solid var(--color-border);
308- border-radius: var(--radius-md);
309- padding: var(--space-3) var(--space-4);
310- display: flex;
311- align-items: center;
312- gap: var(--space-3);
313- white-space: nowrap;
314- z-index: 100;
315- font-size: var(--text-sm);
316- color: var(--color-text-muted);
317- }
318-}
319-
320-/* ============================================================
321- @layer components
322- ============================================================ */
323-@layer components {
324- /* --- Buttons --- */
325- .btn {
326- display: inline-flex;
327- align-items: center;
328- gap: var(--space-2);
329- padding: var(--space-2) var(--space-4);
330- border: 1px solid var(--color-border);
331- border-radius: var(--radius-md);
332- font-size: var(--text-sm);
333- font-weight: 500;
334- text-decoration: none;
335- cursor: pointer;
336- background: var(--color-bg-subtle);
337- color: var(--color-text);
338- transition:
339- background 0.1s,
340- border-color 0.1s;
341- white-space: nowrap;
342- }
343- .btn:hover {
344- background: var(--color-bg-inset);
345- border-color: var(--color-border);
346- }
347- .btn:disabled {
348- opacity: 0.5;
349- cursor: not-allowed;
350- }
351- .btn-primary {
352- background: var(--color-accent);
353- border-color: transparent;
354- color: #fff;
355- }
356- .btn-primary:hover {
357- background: color-mix(in srgb, var(--color-accent) 82%, #000);
358- }
359- .btn-secondary {
360- background: var(--color-bg-subtle);
361- border-color: var(--color-border);
362- }
363- .btn-sm {
364- padding: var(--space-1) var(--space-3);
365- font-size: var(--text-xs);
366- }
367- .btn-block {
368- width: 100%;
369- justify-content: center;
370- }
371-
372- /* --- Forms --- */
373- .form-group {
374- display: flex;
375- flex-direction: column;
376- gap: var(--space-2);
377- margin-bottom: var(--space-4);
378- }
379- .form-group label,
380- .form-group .form-group-label {
381- font-size: var(--text-sm);
382- font-weight: 500;
383- color: var(--color-text);
384- }
385- .form-group input:not([type="file"]):not([type="checkbox"]),
386- .form-group textarea,
387- .form-group select {
388- padding: var(--space-2) var(--space-3);
389- border: 1px solid var(--color-border);
390- border-radius: var(--radius-md);
391- background: var(--color-bg);
392- color: var(--color-text);
393- font-size: var(--text-sm);
394- }
395- .form-group input:not([type="file"]):not([type="checkbox"]):focus,
396- .form-group textarea:focus {
397- outline: 2px solid var(--color-accent);
398- outline-offset: -1px;
399- border-color: var(--color-accent);
400- }
401- .form-group input[type="file"] {
402- font-size: var(--text-sm);
403- color: var(--color-text);
404- padding: 0;
405- border: none;
406- background: none;
407- }
408- .form-group textarea {
409- resize: vertical;
410- min-height: 120px;
411- }
412- .form-success {
413- background: var(--color-success-bg);
414- color: var(--color-success);
415- border: 1px solid var(--color-success);
416- border-radius: var(--radius-md);
417- padding: var(--space-3) var(--space-4);
418- font-size: var(--text-sm);
419- margin-bottom: var(--space-4);
420- }
421- .form-hint {
422- font-size: var(--text-sm);
423- color: var(--color-text-muted);
424- margin: 0;
425- }
426- .form-error {
427- background: var(--color-danger-bg);
428- color: var(--color-danger);
429- border: 1px solid var(--color-danger);
430- border-radius: var(--radius-md);
431- padding: var(--space-3) var(--space-4);
432- font-size: var(--text-sm);
433- margin-bottom: var(--space-4);
434- }
435- .form-actions {
436- display: flex;
437- gap: var(--space-3);
438- align-items: center;
439- flex-wrap: wrap;
440- }
441- .form-card {
442- background: var(--color-bg-subtle);
443- border: 1px solid var(--color-border);
444- border-radius: var(--radius-lg);
445- padding: var(--space-6);
446- margin-top: var(--space-4);
447- }
448- .checkbox-label {
449- display: flex;
450- align-items: center;
451- gap: var(--space-2);
452- cursor: pointer;
453- }
454-
455- /* --- Page header --- */
456- .page-header {
457- display: flex;
458- align-items: center;
459- justify-content: space-between;
460- margin-bottom: var(--space-6);
461- gap: var(--space-4);
462- }
463- .page-title {
464- font-size: var(--text-2xl);
465- color: var(--color-text);
466- margin-bottom: var(--space-4);
467- }
468- .page-title a {
469- text-decoration: none;
470- color: var(--color-link);
471- }
472- .page-title a:hover {
473- text-decoration: underline;
474- }
475- .section-title {
476- font-size: var(--text-lg);
477- margin-bottom: var(--space-4);
478- padding-bottom: var(--space-2);
479- border-bottom: 1px solid var(--color-border);
480- }
481-
482- /* --- Auth --- */
483- .auth-container {
484- max-width: 400px;
485- margin: var(--space-12) auto;
486- }
487- .auth-container .page-title {
488- margin-bottom: var(--space-6);
489- }
490- .auth-form {
491- margin-bottom: var(--space-4);
492- }
493- .auth-footer {
494- text-align: center;
495- font-size: var(--text-sm);
496- color: var(--color-text-muted);
497- margin-top: var(--space-4);
498- }
499- .passkey-btn {
500- display: none;
501- margin-top: var(--space-3);
502- }
503-
504- /* --- Repo header --- */
505- .repo-header {
506- margin-bottom: var(--space-2);
507- }
508- .repo-title-row {
509- display: flex;
510- align-items: center;
511- gap: var(--space-3);
512- flex-wrap: wrap;
513- }
514- .repo-title-row .page-title {
515- margin-bottom: 0;
516- }
517- .repo-header-title {
518- display: flex;
519- align-items: center;
520- gap: var(--space-3);
521- flex-wrap: wrap;
522- }
523- .repo-description {
524- color: var(--color-text-muted);
525- margin-top: var(--space-2);
526- }
527- .repo-header-meta {
528- display: flex;
529- align-items: center;
530- gap: var(--space-4);
531- margin-bottom: var(--space-3);
532- font-size: var(--text-sm);
533- color: var(--color-text-muted);
534- }
535- .icon {
536- font-style: normal;
537- }
538-
539- /* --- Branch selector --- */
540- .branch-selector {
541- display: inline-flex;
542- align-items: center;
543- gap: var(--space-2);
544- }
545- .branch-selector-icon {
546- color: var(--color-text-muted);
547- font-size: var(--text-sm);
548- user-select: none;
549- }
550- .branch-select {
551- padding: var(--space-1) var(--space-2);
552- padding-right: var(--space-5);
553- border: 1px solid var(--color-border);
554- border-radius: var(--radius-md);
555- background: var(--color-bg-subtle);
556- font-size: var(--text-sm);
557- font-family: var(--font-mono);
558- color: var(--color-text);
559- cursor: pointer;
560- appearance: auto;
561- max-width: 180px;
562- }
563- .branch-select:hover {
564- border-color: var(--color-text-muted);
565- }
566- .branch-select:focus {
567- outline: 2px solid var(--color-accent);
568- outline-offset: -1px;
569- }
570-
571- /* toolbar row above file tree */
572- .tree-toolbar {
573- display: flex;
574- align-items: center;
575- justify-content: space-between;
576- margin-bottom: var(--space-3);
577- }
578-
579- /* commits-header flex row */
580- .commits-header {
581- display: flex;
582- align-items: center;
583- gap: var(--space-4);
584- margin-bottom: var(--space-4);
585- flex-wrap: wrap;
586- }
587- .commits-header .section-title {
588- margin-bottom: 0;
589- }
590-
591- /* --- Repo nav bar (tabs + private badge) --- */
592- .repo-nav-bar {
593- display: flex;
594- align-items: center;
595- justify-content: space-between;
596- border-bottom: 1px solid var(--color-border);
597- margin-bottom: var(--space-6);
598- gap: var(--space-4);
599- }
600-
601- /* --- Repo tabs --- */
602- .repo-tabs {
603- display: flex;
604- gap: 0;
605- flex: 1;
606- overflow-x: auto;
607- overflow-y: hidden;
608- }
609- .repo-tab {
610- padding: var(--space-3) var(--space-4);
611- font-size: var(--text-sm);
612- text-decoration: none;
613- color: var(--color-text-muted);
614- border-bottom: 2px solid transparent;
615- margin-bottom: -1px;
616- transition: color 0.1s;
617- }
618- .repo-tab:hover {
619- color: var(--color-text);
620- }
621- .repo-tab.active {
622- color: var(--color-text);
623- font-weight: 600;
624- border-bottom-color: var(--color-accent);
625- }
626- /* Align active indicator with nav-bar border */
627- .repo-tab {
628- margin-bottom: -1px;
629- }
630-
631- /* --- Repo list --- */
632- .repo-list {
633- display: flex;
634- flex-direction: column;
635- gap: var(--space-2);
636- }
637- .repo-card {
638- display: flex;
639- align-items: flex-start;
640- justify-content: space-between;
641- padding: var(--space-4) var(--space-5);
642- border: 1px solid var(--color-border);
643- border-radius: var(--radius-lg);
644- background: var(--color-bg);
645- gap: var(--space-4);
646- transition: border-color 0.15s;
647- }
648- .repo-card:hover {
649- border-color: var(--color-accent);
650- }
651- .repo-card-main {
652- flex: 1;
653- min-width: 0;
654- }
655- .repo-card-title {
656- display: flex;
657- align-items: center;
658- gap: var(--space-2);
659- margin-bottom: var(--space-1);
660- }
661- .repo-name {
662- font-size: var(--text-lg);
663- font-weight: 600;
664- color: var(--color-link);
665- text-decoration: none;
666- word-break: break-all;
667- }
668- .repo-name:hover {
669- text-decoration: underline;
670- }
671- .repo-card-meta {
672- display: flex;
673- flex-direction: column;
674- align-items: flex-end;
675- gap: var(--space-1);
676- font-size: var(--text-xs);
677- color: var(--color-text-muted);
678- white-space: nowrap;
679- }
680- .repo-date {
681- color: var(--color-text-muted);
682- }
683-
684- /* --- Badges --- */
685- .badge {
686- display: inline-flex;
687- align-items: center;
688- padding: 1px var(--space-2);
689- border-radius: var(--radius-full);
690- font-size: var(--text-xs);
691- font-weight: 500;
692- border: 1px solid;
693- text-decoration: none;
694- }
695- .badge-private {
696- color: var(--color-warning);
697- border-color: var(--color-warning);
698- background: var(--color-warning-bg);
699- }
700- .badge-pinned {
701- color: var(--color-accent);
702- border-color: var(--color-accent);
703- background: var(--color-accent-bg);
704- }
705- .issue-badge,
706- .patch-badge {
707- display: inline-flex;
708- align-items: center;
709- padding: var(--space-1) var(--space-3);
710- border-radius: var(--radius-full);
711- font-size: var(--text-xs);
712- font-weight: 500;
713- text-transform: capitalize;
714- }
715- .issue-badge.open,
716- .patch-badge.open {
717- background: var(--color-success-bg);
718- color: var(--color-success);
719- }
720- .issue-badge.closed,
721- .patch-badge.closed {
722- background: var(--color-danger-bg);
723- color: var(--color-danger);
724- }
725- .issue-badge.completed {
726- background: var(--color-merged-bg);
727- color: var(--color-merged);
728- }
729- .patch-badge.merged {
730- background: var(--color-merged-bg);
731- color: var(--color-merged);
732- }
733- .sig-badge {
734- display: inline-flex;
735- align-items: center;
736- padding: var(--space-1) var(--space-3);
737- border-radius: var(--radius-full);
738- font-size: var(--text-xs);
739- font-weight: 500;
740- }
741- .sig-badge.verified {
742- background: var(--color-success-bg);
743- color: var(--color-success);
744- }
745- .sig-badge.unverified {
746- background: var(--color-danger-bg);
747- color: var(--color-danger);
748- }
749-
750- /* --- Empty state --- */
751- .empty-state {
752- text-align: center;
753- padding: var(--space-16) var(--space-4);
754- color: var(--color-text-muted);
755- }
756- .empty-state p {
757- margin-bottom: var(--space-4);
758- font-size: var(--text-lg);
759- }
760-
761- /* --- Breadcrumb --- */
762- .breadcrumb {
763- display: flex;
764- align-items: center;
765- flex-wrap: wrap;
766- gap: var(--space-1);
767- font-size: var(--text-sm);
768- margin-bottom: var(--space-4);
769- }
770- .breadcrumb a {
771- color: var(--color-link);
772- text-decoration: none;
773- }
774- .breadcrumb a:hover {
775- text-decoration: underline;
776- }
777- .breadcrumb-sep {
778- color: var(--color-text-muted);
779- }
780- .breadcrumb-current {
781- color: var(--color-text-muted);
782- }
783-
784- /* --- File tree --- */
785- .file-tree {
786- width: 100%;
787- border: 1px solid var(--color-border);
788- border-radius: var(--radius-lg);
789- border-collapse: collapse;
790- overflow: hidden;
791- margin-bottom: var(--space-6);
792- }
793- .file-tree-row {
794- border-bottom: 1px solid var(--color-border-muted);
795- }
796- .file-tree-row:last-child {
797- border-bottom: none;
798- }
799- .file-tree-row:hover {
800- background: var(--color-bg-subtle);
801- }
802- .file-icon,
803- .file-name,
804- .file-size {
805- padding: var(--space-2) var(--space-3);
806- font-size: var(--text-sm);
807- vertical-align: middle;
808- }
809- .file-icon {
810- width: 36px;
811- padding-right: var(--space-1);
812- text-align: center;
813- line-height: 0;
814- }
815- .file-name {
816- width: 100%;
817- }
818- .file-name a {
819- color: var(--color-link);
820- text-decoration: none;
821- }
822- .file-name a:hover {
823- text-decoration: underline;
824- }
825- .file-name a.file-name-dir {
826- font-weight: 500;
827- }
828- .file-size {
829- text-align: right;
830- color: var(--color-text-muted);
831- font-size: var(--text-xs);
832- white-space: nowrap;
833- padding-right: var(--space-4);
834- }
835- .file-icon-dir {
836- color: var(--color-link);
837- }
838- .file-icon-file {
839- color: var(--color-text-muted);
840- }
841- .tree-icon {
842- display: inline-block;
843- vertical-align: text-bottom;
844- }
845- .file-tree-row-up .file-name a {
846- color: var(--color-text-muted);
847- }
848- .file-tree-row-up .file-name a:hover {
849- color: var(--color-link);
850- }
851-
852- /* --- File blob --- */
853- .file-blob-header {
854- display: flex;
855- align-items: center;
856- justify-content: space-between;
857- padding: var(--space-3) var(--space-4);
858- background: var(--color-bg-subtle);
859- border: 1px solid var(--color-border);
860- border-radius: var(--radius-lg) var(--radius-lg) 0 0;
861- margin-top: var(--space-4);
862- }
863- .file-blob-name {
864- font-size: var(--text-sm);
865- font-weight: 500;
866- }
867- .file-blob-actions {
868- display: flex;
869- align-items: center;
870- gap: var(--space-2);
871- }
872- .file-blob-body {
873- border: 1px solid var(--color-border);
874- border-top: none;
875- border-radius: 0 0 var(--radius-lg) var(--radius-lg);
876- overflow: auto;
877- }
878- .file-edit-textarea {
879- display: block;
880- width: 100%;
881- box-sizing: border-box;
882- font-family: var(--font-mono);
883- font-size: var(--text-sm);
884- line-height: 1.6;
885- padding: var(--space-4);
886- background: var(--color-bg);
887- color: var(--color-text);
888- border: none;
889- resize: vertical;
890- min-height: 400px;
891- }
892- .file-edit-textarea:focus {
893- outline: 2px solid var(--color-accent);
894- outline-offset: -2px;
895- }
896- /* Blob line-number table */
897- .blob-table {
898- min-width: 100%;
899- border-collapse: collapse;
900- font-family: var(--font-mono);
901- font-size: var(--text-sm);
902- line-height: 1.6;
903- background-color: var(--shiki-light-bg, #fff);
904- color: var(--shiki-light, inherit);
905- }
906- .blob-ln {
907- width: 1%;
908- min-width: 48px;
909- text-align: right;
910- padding: 0 var(--space-3);
911- color: var(--color-text-muted);
912- border-right: 1px solid var(--color-border-muted);
913- user-select: none;
914- white-space: nowrap;
915- vertical-align: top;
916- }
917- .blob-ln a {
918- color: inherit;
919- text-decoration: none;
920- }
921- .blob-ln a:hover {
922- color: var(--color-link);
923- }
924- .blob-code {
925- padding: 0 var(--space-4);
926- white-space: pre;
927- overflow: visible;
928- vertical-align: top;
929- }
930- .blob-table tr:target {
931- background: color-mix(in srgb, #ddf4ff 60%, transparent);
932- }
933- .blob-table tr:target .blob-ln {
934- background: color-mix(in srgb, #ddf4ff 80%, transparent);
935- }
936- .file-download-notice {
937- padding: var(--space-8);
938- text-align: center;
939- color: var(--color-text-muted);
940- }
941- .file-download-notice p {
942- margin-bottom: var(--space-4);
943- }
944- .file-media {
945- padding: var(--space-4);
946- display: flex;
947- justify-content: center;
948- background: var(--color-canvas-subtle);
949- }
950- .file-media-img {
951- max-width: 100%;
952- height: auto;
953- display: block;
954- }
955- .file-media-audio {
956- width: 100%;
957- max-width: 600px;
958- }
959- .file-media-video {
960- max-width: 100%;
961- max-height: 80vh;
962- }
963-
964- /* --- Commit list --- */
965- .commit-list {
966- display: flex;
967- flex-direction: column;
968- }
969- .commit-item {
970- display: flex;
971- flex-direction: column;
972- padding: var(--space-3) 0;
973- border-bottom: 1px solid var(--color-border-muted);
974- gap: var(--space-2);
975- }
976- .commit-item:last-child {
977- border-bottom: none;
978- }
979- .commit-subject {
980- color: var(--color-text);
981- text-decoration: none;
982- font-size: var(--text-sm);
983- font-weight: 500;
984- }
985- .commit-subject:hover {
986- color: var(--color-link);
987- }
988- .commit-meta {
989- display: flex;
990- align-items: center;
991- justify-content: space-between;
992- gap: var(--space-3);
993- font-size: var(--text-xs);
994- color: var(--color-text-muted);
995- }
996- .commit-meta-right {
997- display: flex;
998- align-items: center;
999- gap: var(--space-3);
1000- white-space: nowrap;
1001- }
1002- .commit-hash {
1003- font-family: var(--font-mono);
1004- color: var(--color-link);
1005- text-decoration: none;
1006- background: var(--color-bg-inset);
1007- padding: 1px var(--space-2);
1008- border-radius: var(--radius-sm);
1009- }
1010- .commit-hash:hover {
1011- text-decoration: underline;
1012- }
1013- .mono {
1014- font-family: var(--font-mono);
1015- }
1016-
1017- /* --- Commit detail viewer --- */
1018- .commit-page-top {
1019- display: flex;
1020- align-items: center;
1021- justify-content: space-between;
1022- margin-bottom: var(--space-4);
1023- }
1024- .commit-card {
1025- border: 1px solid var(--color-border);
1026- border-radius: var(--radius-lg);
1027- margin-bottom: var(--space-4);
1028- overflow: hidden;
1029- }
1030- .commit-card-subject {
1031- font-size: var(--text-xl);
1032- font-weight: 600;
1033- padding: var(--space-5) var(--space-6);
1034- border-bottom: 1px solid var(--color-border);
1035- line-height: 1.3;
1036- }
1037- .commit-card-subject-empty {
1038- color: var(--color-text-muted);
1039- font-style: italic;
1040- font-weight: 400;
1041- font-size: var(--text-sm);
1042- }
1043- .commit-card-body {
1044- padding: var(--space-4) var(--space-6);
1045- font-family: inherit;
1046- font-size: var(--text-sm);
1047- color: var(--color-text-muted);
1048- border-bottom: 1px solid var(--color-border);
1049- overflow-x: auto;
1050- }
1051- .commit-card-meta {
1052- padding: var(--space-4) var(--space-6);
1053- display: flex;
1054- flex-direction: column;
1055- gap: var(--space-2);
1056- background: var(--color-bg-subtle);
1057- }
1058- .commit-card-meta-row {
1059- display: flex;
1060- align-items: baseline;
1061- gap: var(--space-3);
1062- font-size: var(--text-sm);
1063- flex-wrap: wrap;
1064- }
1065- .commit-meta-label {
1066- font-weight: 600;
1067- color: var(--color-text-muted);
1068- min-width: 5rem;
1069- font-size: var(--text-xs);
1070- text-transform: uppercase;
1071- letter-spacing: 0.04em;
1072- }
1073- .commit-sha-full {
1074- font-size: var(--text-xs);
1075- background: var(--color-bg-inset);
1076- padding: 2px var(--space-2);
1077- border-radius: var(--radius-sm);
1078- word-break: break-all;
1079- }
1080- .commit-stats-bar {
1081- font-size: var(--text-sm);
1082- color: var(--color-text-muted);
1083- margin-bottom: var(--space-4);
1084- }
1085-
1086- /* --- Commit layout (sidebar + diffs) --- */
1087- .commit-layout {
1088- display: grid;
1089- grid-template-columns: 1fr;
1090- gap: var(--space-5);
1091- align-items: start;
1092- }
1093- .commit-diffs {
1094- min-width: 0;
1095- }
1096- @media (min-width: 900px) {
1097- .commit-layout {
1098- grid-template-columns: 220px 1fr;
1099- }
1100- .commit-layout:has(.file-nav-details:not([open])) {
1101- grid-template-columns: auto 1fr;
1102- }
1103- }
1104-
1105- /* --- File nav sidebar --- */
1106- .commit-file-nav {
1107- position: sticky;
1108- top: calc(56px + var(--space-4));
1109- max-height: calc(100vh - 140px);
1110- overflow-y: auto;
1111- }
1112- .file-nav-details {
1113- border: 1px solid var(--color-border);
1114- border-radius: var(--radius-lg);
1115- background: var(--color-bg-subtle);
1116- overflow: hidden;
1117- }
1118- .file-nav-toggle {
1119- display: flex;
1120- align-items: center;
1121- gap: var(--space-2);
1122- padding: var(--space-3) var(--space-4);
1123- font-size: var(--text-sm);
1124- font-weight: 500;
1125- cursor: pointer;
1126- list-style: none;
1127- user-select: none;
1128- border-bottom: 1px solid var(--color-border);
1129- }
1130- .file-nav-toggle::-webkit-details-marker {
1131- display: none;
1132- }
1133- .file-nav-details:not([open]) .file-nav-toggle {
1134- border-bottom: none;
1135- }
1136- .file-nav-toggle-icon {
1137- font-size: 1em;
1138- transition: transform 0.15s;
1139- }
1140- .file-nav-details:not([open]) .file-nav-toggle-icon {
1141- transform: rotate(-90deg);
1142- }
1143- .file-nav-list {
1144- padding: var(--space-1) 0;
1145- list-style: none;
1146- }
1147- .file-nav-item {
1148- display: flex;
1149- align-items: center;
1150- gap: var(--space-2);
1151- padding: var(--space-1) var(--space-3);
1152- font-size: var(--text-xs);
1153- text-decoration: none;
1154- color: var(--color-text);
1155- border-radius: 0;
1156- overflow: hidden;
1157- }
1158- .file-nav-item:hover {
1159- background: var(--color-bg-inset);
1160- }
1161- .file-nav-status {
1162- flex-shrink: 0;
1163- width: 16px;
1164- height: 16px;
1165- display: inline-flex;
1166- align-items: center;
1167- justify-content: center;
1168- border-radius: var(--radius-sm);
1169- font-size: 9px;
1170- font-weight: 700;
1171- font-family: var(--font-mono);
1172- }
1173- .file-status-added {
1174- background: var(--color-success-bg);
1175- color: var(--color-success);
1176- }
1177- .file-status-deleted {
1178- background: var(--color-danger-bg);
1179- color: var(--color-danger);
1180- }
1181- .file-status-modified {
1182- background: var(--color-accent-bg);
1183- color: var(--color-accent);
1184- }
1185- .file-status-renamed {
1186- background: var(--color-merged-bg);
1187- color: var(--color-merged);
1188- }
1189- .file-status-copied {
1190- background: var(--color-merged-bg);
1191- color: var(--color-merged);
1192- }
1193- .file-nav-name {
1194- flex: 1;
1195- overflow: hidden;
1196- text-overflow: ellipsis;
1197- white-space: nowrap;
1198- font-family: var(--font-mono);
1199- }
1200- .file-nav-stat {
1201- flex-shrink: 0;
1202- display: flex;
1203- gap: var(--space-1);
1204- font-size: var(--text-xs);
1205- font-family: var(--font-mono);
1206- }
1207- .nav-add {
1208- color: var(--color-success);
1209- }
1210- .nav-del {
1211- color: var(--color-danger);
1212- }
1213- .nav-binary {
1214- color: var(--color-muted);
1215- font-size: var(--text-xs);
1216- }
1217- .file-nav-dir > details > .file-nav-list {
1218- padding-left: var(--space-4);
1219- }
1220- .file-nav-dir-toggle {
1221- display: flex;
1222- align-items: center;
1223- gap: var(--space-1);
1224- padding: var(--space-1) var(--space-3);
1225- font-size: var(--text-xs);
1226- font-family: var(--font-mono);
1227- color: var(--color-text-muted);
1228- cursor: pointer;
1229- list-style: none;
1230- user-select: none;
1231- }
1232- .file-nav-dir-toggle::-webkit-details-marker {
1233- display: none;
1234- }
1235- .file-nav-dir > details:not([open]) .file-nav-toggle-icon {
1236- transform: rotate(-90deg);
1237- }
1238-
1239- /* --- Diff file cards --- */
1240- .diff-file {
1241- border: 1px solid var(--color-border);
1242- border-radius: var(--radius-lg);
1243- margin-bottom: var(--space-4);
1244- overflow: hidden;
1245- }
1246- .diff-file-header {
1247- display: flex;
1248- align-items: center;
1249- justify-content: space-between;
1250- gap: var(--space-3);
1251- padding: var(--space-3) var(--space-4);
1252- background: var(--color-bg-subtle);
1253- cursor: pointer;
1254- list-style: none;
1255- user-select: none;
1256- flex-wrap: wrap;
1257- }
1258- .diff-file-header::-webkit-details-marker {
1259- display: none;
1260- }
1261- .diff-file-header-left {
1262- display: flex;
1263- align-items: center;
1264- gap: var(--space-2);
1265- min-width: 0;
1266- flex: 1;
1267- }
1268- .diff-file-header-right {
1269- display: flex;
1270- align-items: center;
1271- gap: var(--space-2);
1272- flex-shrink: 0;
1273- flex-wrap: wrap;
1274- }
1275- .diff-file-toggle-icon {
1276- font-size: 0.7em;
1277- flex-shrink: 0;
1278- transition: transform 0.15s;
1279- }
1280- .diff-file:not([open]) .diff-file-toggle-icon {
1281- transform: rotate(-90deg);
1282- }
1283- .diff-status-badge {
1284- flex-shrink: 0;
1285- width: 18px;
1286- height: 18px;
1287- display: inline-flex;
1288- align-items: center;
1289- justify-content: center;
1290- border-radius: var(--radius-sm);
1291- font-size: 10px;
1292- font-weight: 700;
1293- font-family: var(--font-mono);
1294- }
1295- .diff-status-added {
1296- background: var(--color-success-bg);
1297- color: var(--color-success);
1298- }
1299- .diff-status-deleted {
1300- background: var(--color-danger-bg);
1301- color: var(--color-danger);
1302- }
1303- .diff-status-modified {
1304- background: var(--color-accent-bg);
1305- color: var(--color-accent);
1306- }
1307- .diff-status-renamed {
1308- background: var(--color-merged-bg);
1309- color: var(--color-merged);
1310- }
1311- .diff-status-copied {
1312- background: var(--color-merged-bg);
1313- color: var(--color-merged);
1314- }
1315- .diff-file-path {
1316- font-size: var(--text-sm);
1317- font-weight: 500;
1318- overflow: hidden;
1319- text-overflow: ellipsis;
1320- white-space: nowrap;
1321- }
1322- .diff-rename-arrow {
1323- font-size: var(--text-xs);
1324- color: var(--color-text-muted);
1325- white-space: nowrap;
1326- overflow: hidden;
1327- text-overflow: ellipsis;
1328- }
1329- .diff-stat-add {
1330- color: var(--color-success);
1331- font-size: var(--text-sm);
1332- font-family: var(--font-mono);
1333- font-weight: 600;
1334- }
1335- .diff-stat-del {
1336- color: var(--color-danger);
1337- font-size: var(--text-sm);
1338- font-family: var(--font-mono);
1339- font-weight: 600;
1340- }
1341- .diff-stat-binary {
1342- color: var(--color-muted);
1343- font-size: var(--text-sm);
1344- font-family: var(--font-mono);
1345- }
1346- .btn-xs {
1347- padding: 1px var(--space-2);
1348- font-size: var(--text-xs);
1349- border: 1px solid var(--color-border);
1350- border-radius: var(--radius-sm);
1351- background: var(--color-bg);
1352- color: var(--color-text);
1353- text-decoration: none;
1354- cursor: pointer;
1355- white-space: nowrap;
1356- font-family: var(--font-mono);
1357- display: inline-flex;
1358- align-items: center;
1359- transition: background 0.1s;
1360- }
1361- .btn-xs:hover {
1362- background: var(--color-bg-inset);
1363- }
1364-
1365- /* --- Diff file body --- */
1366- .diff-binary-notice {
1367- padding: var(--space-4) var(--space-6);
1368- font-size: var(--text-sm);
1369- color: var(--color-text-muted);
1370- font-style: italic;
1371- }
1372- .diff-hunk {
1373- border-top: 1px solid var(--color-border-muted);
1374- overflow-x: auto;
1375- }
1376- .diff-hunk:first-child {
1377- border-top: none;
1378- }
1379- .diff-hunk-header {
1380- padding: var(--space-1) var(--space-4);
1381- font-family: var(--font-mono);
1382- font-size: var(--text-xs);
1383- font-weight: normal;
1384- text-align: left;
1385- background: color-mix(
1386- in srgb,
1387- var(--color-merged-bg) 50%,
1388- var(--color-bg)
1389- );
1390- color: var(--color-merged);
1391- border-bottom: 1px solid var(--color-border-muted);
1392- white-space: pre;
1393- }
1394-
1395- /* --- Diff table --- */
1396- .diff-table {
1397- min-width: 100%;
1398- border-collapse: collapse;
1399- font-family: var(--font-mono);
1400- font-size: var(--text-xs);
1401- line-height: 1.5;
1402- }
1403- .diff-ln {
1404- width: 44px;
1405- min-width: 44px;
1406- text-align: right;
1407- padding: 0 var(--space-2);
1408- color: var(--color-text-muted);
1409- border-right: 1px solid var(--color-border-muted);
1410- user-select: none;
1411- vertical-align: top;
1412- white-space: nowrap;
1413- }
1414- .diff-sign {
1415- width: 18px;
1416- min-width: 18px;
1417- text-align: center;
1418- padding: 0 var(--space-1);
1419- user-select: none;
1420- vertical-align: top;
1421- }
1422- .diff-code {
1423- padding: 0 var(--space-3);
1424- white-space: pre;
1425- overflow: visible;
1426- vertical-align: top;
1427- word-break: normal;
1428- }
1429- /* Row type backgrounds */
1430- .diff-row-add {
1431- background: color-mix(
1432- in srgb,
1433- var(--color-success-bg) 70%,
1434- transparent
1435- );
1436- }
1437- .diff-row-del {
1438- background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent);
1439- }
1440- .diff-row-add .diff-ln,
1441- .diff-row-add .diff-sign {
1442- background: color-mix(
1443- in srgb,
1444- var(--color-success-bg) 90%,
1445- transparent
1446- );
1447- }
1448- .diff-row-del .diff-ln,
1449- .diff-row-del .diff-sign {
1450- background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent);
1451- }
1452- .diff-row-add .diff-sign {
1453- color: var(--color-success);
1454- font-weight: 700;
1455- }
1456- .diff-row-del .diff-sign {
1457- color: var(--color-danger);
1458- font-weight: 700;
1459- }
1460-
1461- .diff-ctrl {
1462- color: #f0f0f0;
1463- background: #c0392b;
1464- border-radius: 2px;
1465- font-weight: bold;
1466- padding: 0 1px;
1467- }
1468-
1469- /* Shiki dark-mode support in diff code cells */
1470- @media (prefers-color-scheme: dark) {
1471- .diff-code span[style] {
1472- /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
1473- color: var(--shiki-dark) !important;
1474- }
1475- }
1476-
1477- /* Shiki dark-mode support in file blob view */
1478- @media (prefers-color-scheme: dark) {
1479- .blob-table {
1480- background-color: var(--shiki-dark-bg);
1481- color: var(--shiki-dark);
1482- }
1483- .blob-table span[style] {
1484- /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
1485- color: var(--shiki-dark) !important;
1486- }
1487- .blob-table tr:target {
1488- background: color-mix(in srgb, #1c3a52 60%, transparent);
1489- }
1490- .blob-table tr:target .blob-ln {
1491- background: color-mix(in srgb, #1c3a52 80%, transparent);
1492- }
1493- }
1494- :root[data-theme="dark"] .blob-table {
1495- background-color: var(--shiki-dark-bg);
1496- color: var(--shiki-dark);
1497- }
1498- :root[data-theme="dark"] .blob-table span[style] {
1499- /* biome-ignore lint/complexity/noImportantStyles: required to override shiki inline styles */
1500- color: var(--shiki-dark) !important;
1501- }
1502-
1503- /* --- Issue / Patch list --- */
1504- .list-header {
1505- display: flex;
1506- align-items: center;
1507- justify-content: space-between;
1508- padding: var(--space-3) var(--space-4);
1509- background: var(--color-bg-subtle);
1510- border: 1px solid var(--color-border);
1511- border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1512- gap: var(--space-4);
1513- }
1514- .list-header-tabs {
1515- display: flex;
1516- gap: var(--space-2);
1517- }
1518- .list-tab {
1519- padding: var(--space-1) var(--space-3);
1520- border-radius: var(--radius-md);
1521- font-size: var(--text-sm);
1522- text-decoration: none;
1523- color: var(--color-text-muted);
1524- }
1525- .list-tab:hover {
1526- background: var(--color-bg-inset);
1527- color: var(--color-text);
1528- }
1529- .list-tab.active {
1530- background: var(--color-bg-inset);
1531- color: var(--color-text);
1532- font-weight: 600;
1533- }
1534- .issue-list {
1535- border: 1px solid var(--color-border);
1536- border-top: none;
1537- border-radius: 0 0 var(--radius-lg) var(--radius-lg);
1538- }
1539- .issue-item {
1540- padding: var(--space-4) var(--space-4);
1541- border-bottom: 1px solid var(--color-border-muted);
1542- }
1543- .issue-item:last-child {
1544- border-bottom: none;
1545- }
1546- .issue-main {
1547- display: flex;
1548- align-items: center;
1549- gap: var(--space-2);
1550- margin-bottom: var(--space-1);
1551- flex-wrap: wrap;
1552- }
1553- .issue-title {
1554- font-weight: 500;
1555- text-decoration: none;
1556- color: var(--color-text);
1557- flex: 1;
1558- }
1559- .issue-title:hover {
1560- color: var(--color-link);
1561- }
1562- .issue-meta {
1563- font-size: var(--text-xs);
1564- color: var(--color-text-muted);
1565- display: flex;
1566- gap: var(--space-3);
1567- }
1568- .issue-status-dot,
1569- .patch-status-dot {
1570- width: 8px;
1571- height: 8px;
1572- border-radius: 50%;
1573- flex-shrink: 0;
1574- }
1575- .issue-status-dot.open,
1576- .patch-status-dot.open {
1577- background: var(--color-success);
1578- }
1579- .issue-status-dot.closed,
1580- .patch-status-dot.closed {
1581- background: var(--color-danger);
1582- }
1583- .issue-status-dot.completed {
1584- background: var(--color-merged);
1585- }
1586- .patch-status-dot.merged {
1587- background: var(--color-merged);
1588- }
1589-
1590- /* --- Issue detail / Timeline --- */
1591- .issue-detail-header {
1592- display: flex;
1593- align-items: center;
1594- flex-wrap: wrap;
1595- gap: var(--space-3);
1596- margin-bottom: var(--space-4);
1597- }
1598- .issue-number {
1599- font-size: var(--text-2xl);
1600- font-weight: 600;
1601- color: var(--color-text-muted);
1602- white-space: nowrap;
1603- }
1604- .issue-detail-title {
1605- font-size: var(--text-2xl);
1606- font-weight: 600;
1607- overflow-wrap: anywhere;
1608- }
1609- .issue-detail-meta-actions {
1610- margin-left: auto;
1611- display: flex;
1612- gap: var(--space-2);
1613- flex-shrink: 0;
1614- }
1615- .patch-author-meta {
1616- display: flex;
1617- align-items: center;
1618- gap: var(--space-2);
1619- font-size: var(--text-xs);
1620- margin-top: calc(-1 * var(--space-4));
1621- margin-bottom: var(--space-6);
1622- }
1623- .patch-author-label {
1624- color: var(--color-text-muted);
1625- }
1626- .patch-author-identity {
1627- font-family: monospace;
1628- }
1629- .timeline-item {
1630- border: 1px solid var(--color-border);
1631- border-radius: var(--radius-lg);
1632- margin-bottom: var(--space-4);
1633- overflow: hidden;
1634- }
1635- .timeline-author {
1636- background: var(--color-bg-subtle);
1637- padding: var(--space-3) var(--space-4);
1638- font-size: var(--text-sm);
1639- display: flex;
1640- align-items: center;
1641- justify-content: space-between;
1642- gap: var(--space-3);
1643- border-bottom: 1px solid var(--color-border);
1644- overflow-wrap: anywhere;
1645- }
1646- .timeline-author time {
1647- color: var(--color-text-muted);
1648- font-size: var(--text-xs);
1649- width: max-content;
1650- }
1651- .timeline-author-right {
1652- margin-left: auto;
1653- display: flex;
1654- align-items: center;
1655- gap: var(--space-2);
1656- }
1657- .edited-indicator {
1658- color: var(--color-text-muted);
1659- font-size: var(--text-xs);
1660- cursor: default;
1661- }
1662-
1663- /* --- Avatars --- */
1664- .avatar {
1665- display: inline-flex;
1666- border-radius: 4px;
1667- overflow: hidden;
1668- flex-shrink: 0;
1669- vertical-align: middle;
1670- }
1671- .avatar-img {
1672- width: 100%;
1673- height: 100%;
1674- object-fit: cover;
1675- display: block;
1676- color: transparent;
1677- }
1678- .avatar-placeholder {
1679- display: inline-flex;
1680- align-items: center;
1681- justify-content: center;
1682- border-radius: 4px;
1683- background: var(--color-bg-inset);
1684- color: var(--color-text-muted);
1685- font-weight: 600;
1686- flex-shrink: 0;
1687- vertical-align: middle;
1688- }
1689- .avatar-settings {
1690- display: flex;
1691- align-items: flex-start;
1692- gap: var(--space-5);
1693- }
1694- .avatar-actions {
1695- display: flex;
1696- flex-direction: column;
1697- gap: var(--space-3);
1698- }
1699- .nav-user {
1700- display: inline-flex;
1701- align-items: center;
1702- gap: var(--space-2);
1703- }
1704- .timeline-body {
1705- padding: var(--space-4);
1706- }
1707- .timeline-item-new {
1708- padding: var(--space-4);
1709- }
1710- .timeline-item-new .section-title {
1711- font-size: var(--text-base);
1712- border: none;
1713- padding-bottom: 0;
1714- }
1715- /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
1716- .timeline-author-right .inline-edit-details {
1717- border: none;
1718- }
1719- .timeline-author-right .inline-edit-details > summary {
1720- list-style: none;
1721- }
1722- .timeline-author-right
1723- .inline-edit-details
1724- > summary::-webkit-details-marker {
1725- display: none;
1726- }
1727- .inline-edit-details .when-open {
1728- display: none;
1729- }
1730- .inline-edit-details[open] .when-closed {
1731- display: none;
1732- }
1733- .inline-edit-details[open] .when-open {
1734- display: inline;
1735- }
1736- .inline-edit-form-area {
1737- display: none;
1738- border-top: 1px solid var(--color-border-muted);
1739- }
1740- .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area {
1741- display: block;
1742- }
1743- .timeline-item:has(.inline-edit-details[open]) .timeline-body {
1744- display: none;
1745- }
1746-
1747- /* Title edit in issue/patch header */
1748- .issue-detail-header {
1749- align-items: flex-start;
1750- }
1751- .title-with-edit {
1752- flex: 1;
1753- min-width: 16rem;
1754- display: flex;
1755- flex-direction: column;
1756- gap: var(--space-1);
1757- }
1758- .title-edit-details {
1759- border: none;
1760- align-self: flex-start;
1761- }
1762- .title-with-edit:has(.title-edit-details[open]) .issue-detail-title {
1763- display: none;
1764- }
1765- .title-with-edit:has(.title-edit-details[open]) > .title-edit-details {
1766- display: none;
1767- }
1768- .title-edit-details > summary {
1769- list-style: none;
1770- }
1771- .title-edit-details > summary::-webkit-details-marker {
1772- display: none;
1773- }
1774- .title-edit-form-area {
1775- display: none;
1776- }
1777- .title-with-edit:has(.title-edit-details[open]) .title-edit-form-area {
1778- display: flex;
1779- flex: 1;
1780- min-width: 0;
1781- }
1782- .title-edit-form {
1783- display: flex;
1784- flex-direction: column;
1785- gap: var(--space-2);
1786- flex: 1;
1787- min-width: 0;
1788- }
1789- .title-edit-form .form-input {
1790- min-width: 0;
1791- width: 100%;
1792- padding: var(--space-2) var(--space-3);
1793- border: 1px solid var(--color-border);
1794- border-radius: var(--radius-md);
1795- background: var(--color-bg);
1796- color: var(--color-text);
1797- font-size: var(--text-sm);
1798- }
1799- .title-edit-form .form-input:focus {
1800- outline: 2px solid var(--color-accent);
1801- outline-offset: -1px;
1802- border-color: var(--color-accent);
1803- }
1804- .title-edit-actions {
1805- display: flex;
1806- gap: var(--space-2);
1807- }
1808-
1809- .inline-edit-form {
1810- padding: var(--space-4);
1811- display: flex;
1812- flex-direction: column;
1813- gap: var(--space-3);
1814- }
1815-
1816- /* --- Reactions --- */
1817- .reaction-bar {
1818- display: flex;
1819- flex-wrap: wrap;
1820- gap: var(--space-2);
1821- padding: var(--space-2) var(--space-4);
1822- border-top: 1px solid var(--color-border-muted);
1823- }
1824- .reaction-form {
1825- display: inline;
1826- }
1827- .reaction-btn {
1828- display: inline-flex;
1829- align-items: center;
1830- gap: var(--space-1);
1831- padding: var(--space-1) var(--space-3);
1832- border: 1px solid var(--color-border);
1833- border-radius: var(--radius-full);
1834- background: var(--color-bg-subtle);
1835- color: var(--color-text);
1836- font-size: var(--text-sm);
1837- cursor: pointer;
1838- transition: background 0.1s;
1839- }
1840- .reaction-btn:hover {
1841- background: var(--color-bg-inset);
1842- }
1843- .reaction-btn.reacted {
1844- background: var(--color-accent-bg);
1845- border-color: var(--color-accent);
1846- }
1847- .reaction-picker {
1848- position: relative;
1849- }
1850- .reaction-add-btn {
1851- display: inline-flex;
1852- align-items: center;
1853- justify-content: center;
1854- width: 28px;
1855- height: 28px;
1856- border: 1px dashed var(--color-border);
1857- border-radius: var(--radius-full);
1858- background: transparent;
1859- cursor: pointer;
1860- font-size: var(--text-base);
1861- color: var(--color-text-muted);
1862- list-style: none;
1863- }
1864- .reaction-add-btn::-webkit-details-marker {
1865- display: none;
1866- }
1867- .reaction-picker-dropdown {
1868- position: absolute;
1869- bottom: calc(100% + var(--space-2));
1870- left: 0;
1871- background: var(--color-bg);
1872- border: 1px solid var(--color-border);
1873- border-radius: var(--radius-lg);
1874- padding: var(--space-2);
1875- display: flex;
1876- gap: var(--space-1);
1877- z-index: 10;
1878- }
1879- .reaction-picker-btn {
1880- background: none;
1881- border: none;
1882- font-size: 1.25rem;
1883- cursor: pointer;
1884- padding: var(--space-1);
1885- border-radius: var(--radius-sm);
1886- }
1887- .reaction-picker-btn:hover {
1888- background: var(--color-bg-inset);
1889- }
1890-
1891- /* --- Patch subview tabs --- */
1892- .patch-tab-nav {
1893- display: flex;
1894- border-bottom: 1px solid var(--color-border);
1895- margin-bottom: var(--space-6);
1896- }
1897- .tab-count {
1898- display: inline-flex;
1899- align-items: center;
1900- justify-content: center;
1901- min-width: 1.4em;
1902- padding: 0 var(--space-1);
1903- margin-left: var(--space-1);
1904- font-size: var(--text-xs);
1905- font-weight: 500;
1906- background: var(--color-bg-inset);
1907- border-radius: 999px;
1908- color: var(--color-text-muted);
1909- }
1910-
1911- /* --- Patch apply status --- */
1912- .apply-status {
1913- margin: var(--space-4) 0;
1914- }
1915- .apply-result {
1916- display: flex;
1917- align-items: flex-start;
1918- gap: var(--space-2);
1919- padding: var(--space-3) var(--space-4);
1920- border-radius: var(--radius-md);
1921- font-size: var(--text-sm);
1922- flex-direction: column;
1923- }
1924- .apply-result > div {
1925- display: flex;
1926- gap: var(--space-2);
1927- align-items: center;
1928- }
1929- .apply-clean {
1930- background: var(--color-success-bg);
1931- color: var(--color-success);
1932- border: 1px solid var(--color-success);
1933- }
1934- .apply-conflict {
1935- background: var(--color-danger-bg);
1936- color: var(--color-danger);
1937- border: 1px solid var(--color-danger);
1938- }
1939- .apply-checking,
1940- .apply-unknown {
1941- color: var(--color-text-muted);
1942- font-style: italic;
1943- font-size: var(--text-sm);
1944- }
1945- .apply-output {
1946- font-family: var(--font-mono);
1947- font-size: var(--text-xs);
1948- white-space: pre-wrap;
1949- margin-top: var(--space-2);
1950- padding: var(--space-2);
1951- background: rgba(0, 0, 0, 0.05);
1952- border-radius: var(--radius-sm);
1953- width: 100%;
1954- }
1955- .patch-actions {
1956- display: flex;
1957- gap: var(--space-3);
1958- margin: var(--space-4) 0;
1959- flex-wrap: wrap;
1960- }
1961- .patch-card-title-row {
1962- display: flex;
1963- align-items: flex-start;
1964- gap: var(--space-3);
1965- justify-content: space-between;
1966- margin-bottom: var(--space-3);
1967- }
1968- .patch-card-title-row .commit-card-subject {
1969- margin-bottom: 0;
1970- }
1971- .patch-card-description {
1972- margin-top: var(--space-4);
1973- padding-top: var(--space-4);
1974- border-top: 1px solid var(--color-border-muted);
1975- }
1976-
1977- /* --- Repo home layout --- */
1978- .repo-home-layout {
1979- display: grid;
1980- gap: var(--space-8);
1981- }
1982- @media (min-width: 768px) {
1983- .repo-home-layout {
1984- grid-template-columns: 1fr 1fr;
1985- }
1986- }
1987- .readme-section {
1988- border: 1px solid var(--color-border);
1989- border-radius: var(--radius-lg);
1990- overflow: hidden;
1991- }
1992- .readme-header {
1993- background: var(--color-bg-subtle);
1994- padding: var(--space-3) var(--space-4);
1995- font-size: var(--text-sm);
1996- font-weight: 500;
1997- border-bottom: 1px solid var(--color-border);
1998- display: flex;
1999- align-items: center;
2000- justify-content: space-between;
2001- }
2002- /* --- Code setup block --- */
2003- .code-setup {
2004- background: var(--color-bg-subtle);
2005- border: 1px solid var(--color-border);
2006- border-radius: var(--radius-lg);
2007- padding: var(--space-4);
2008- font-size: var(--text-sm);
2009- margin-top: var(--space-4);
2010- overflow: auto;
2011- text-align: left;
2012- }
2013- .code-plain {
2014- background: var(--color-bg-subtle);
2015- padding: var(--space-4);
2016- overflow: auto;
2017- font-size: var(--text-sm);
2018- line-height: 1.6;
2019- }
2020-
2021- /* --- Markdown body --- */
2022- .markdown-body {
2023- font-size: var(--text-sm);
2024- line-height: 1.6;
2025- color: var(--color-text);
2026- }
2027- .markdown-body h1,
2028- .markdown-body h2,
2029- .markdown-body h3,
2030- .markdown-body h4,
2031- .markdown-body h5,
2032- .markdown-body h6 {
2033- margin-top: var(--space-6);
2034- margin-bottom: var(--space-3);
2035- }
2036- .markdown-body p {
2037- margin-bottom: var(--space-4);
2038- }
2039- .markdown-body a {
2040- color: var(--color-link);
2041- }
2042- .markdown-body code {
2043- background: var(--color-bg-inset);
2044- padding: 0.1em 0.3em;
2045- border-radius: var(--radius-sm);
2046- font-size: 0.9em;
2047- }
2048- .markdown-body pre {
2049- background: var(--color-bg-subtle);
2050- border: 1px solid var(--color-border);
2051- border-radius: var(--radius-md);
2052- padding: var(--space-4);
2053- overflow: auto;
2054- margin-bottom: var(--space-4);
2055- }
2056- .markdown-body pre code {
2057- background: none;
2058- padding: 0;
2059- }
2060- .markdown-body ul,
2061- .markdown-body ol {
2062- padding-left: var(--space-6);
2063- margin-bottom: var(--space-4);
2064- }
2065- .markdown-body ul {
2066- list-style: disc;
2067- }
2068- .markdown-body ol {
2069- list-style: decimal;
2070- }
2071- .markdown-body li {
2072- margin-bottom: var(--space-1);
2073- }
2074- .markdown-body blockquote {
2075- border-left: 3px solid var(--color-border);
2076- padding-left: var(--space-4);
2077- color: var(--color-text-muted);
2078- margin: var(--space-4) 0;
2079- }
2080- .markdown-body table {
2081- width: 100%;
2082- border-collapse: collapse;
2083- margin-bottom: var(--space-4);
2084- }
2085- .markdown-body th,
2086- .markdown-body td {
2087- padding: var(--space-2) var(--space-3);
2088- border: 1px solid var(--color-border);
2089- }
2090- .markdown-body th {
2091- background: var(--color-bg-subtle);
2092- font-weight: 600;
2093- }
2094- .markdown-body hr {
2095- border: none;
2096- border-top: 1px solid var(--color-border);
2097- margin: var(--space-6) 0;
2098- }
2099- .markdown-body img {
2100- max-width: 100%;
2101- border-radius: var(--radius-md);
2102- }
2103- .markdown-body details {
2104- margin-bottom: var(--space-4);
2105- }
2106- .markdown-body summary {
2107- cursor: pointer;
2108- font-weight: 500;
2109- }
2110- .readme-section .markdown-body,
2111- .file-blob-body .markdown-body {
2112- padding: var(--space-4);
2113- }
2114-
2115- /* --- Search row (search form + sort select) --- */
2116- .search-row {
2117- display: flex;
2118- align-items: center;
2119- gap: var(--space-3);
2120- margin-bottom: var(--space-5);
2121- }
2122- .search-form {
2123- flex: 1;
2124- }
2125- .repo-sort-select {
2126- padding: var(--space-1) var(--space-2);
2127- padding-right: var(--space-5);
2128- border: 1px solid var(--color-border);
2129- border-radius: var(--radius-md);
2130- background: var(--color-bg-subtle);
2131- font-size: var(--text-sm);
2132- color: var(--color-text);
2133- cursor: pointer;
2134- appearance: auto;
2135- }
2136- .repo-sort-select:hover {
2137- border-color: var(--color-text-muted);
2138- }
2139- .repo-sort-select:focus {
2140- outline: 2px solid var(--color-accent);
2141- outline-offset: -1px;
2142- }
2143- .search-input-wrap {
2144- display: flex;
2145- gap: var(--space-2);
2146- }
2147- .search-input {
2148- flex: 1;
2149- padding: var(--space-2) var(--space-3);
2150- border: 1px solid var(--color-border);
2151- border-radius: var(--radius-md);
2152- background: var(--color-bg);
2153- color: var(--color-text);
2154- font-size: var(--text-sm);
2155- }
2156- .search-input:focus {
2157- outline: 2px solid var(--color-accent);
2158- outline-offset: -1px;
2159- border-color: var(--color-accent);
2160- }
2161-
2162- /* --- Issue close bar --- */
2163- .issue-close-bar {
2164- display: flex;
2165- justify-content: flex-end;
2166- padding: var(--space-3) 0;
2167- border-top: 1px solid var(--color-border-muted);
2168- margin-top: var(--space-2);
2169- }
2170-
2171- /* --- Auth divider --- */
2172- .auth-divider {
2173- display: flex;
2174- align-items: center;
2175- gap: var(--space-3);
2176- margin: var(--space-4) 0;
2177- color: var(--color-text-muted);
2178- font-size: var(--text-sm);
2179- }
2180- .auth-divider::before,
2181- .auth-divider::after {
2182- content: "";
2183- flex: 1;
2184- height: 1px;
2185- background: var(--color-border);
2186- }
2187-
2188- /* --- Passkey UI in settings --- */
2189- .passkey-actions {
2190- display: flex;
2191- flex-direction: column;
2192- gap: var(--space-3);
2193- align-items: flex-start;
2194- margin-top: var(--space-3);
2195- }
2196- .passkey-status {
2197- font-size: var(--text-sm);
2198- }
2199-
2200- /* --- Danger zone --- */
2201- .danger-zone {
2202- margin-top: var(--space-8);
2203- }
2204- .danger-title {
2205- color: var(--color-danger);
2206- border-bottom-color: var(--color-danger);
2207- }
2208- .danger-card {
2209- border-color: var(--color-danger);
2210- background: var(--color-danger-bg);
2211- }
2212- .danger-item {
2213- display: flex;
2214- align-items: center;
2215- justify-content: space-between;
2216- gap: var(--space-4);
2217- flex-wrap: wrap;
2218- }
2219- .danger-item p {
2220- margin: var(--space-1) 0 0;
2221- }
2222- .btn-danger {
2223- background: var(--color-danger);
2224- border-color: transparent;
2225- color: #fff;
2226- }
2227- .btn-danger:hover {
2228- background: color-mix(in srgb, var(--color-danger) 85%, #000);
2229- }
2230-
2231- /* --- Release list items --- */
2232- .release-item-header {
2233- display: flex;
2234- gap: var(--space-3);
2235- align-items: flex-start;
2236- }
2237- .release-item-main {
2238- flex: 1;
2239- min-width: 0;
2240- }
2241- .release-item-title {
2242- font-size: var(--text-lg);
2243- font-weight: 700;
2244- text-decoration: none;
2245- color: var(--color-text);
2246- display: block;
2247- margin-bottom: var(--space-1);
2248- }
2249- .release-item-title:hover {
2250- color: var(--color-link);
2251- }
2252- .release-item-meta {
2253- display: flex;
2254- flex-wrap: wrap;
2255- gap: var(--space-3);
2256- align-items: center;
2257- font-size: var(--text-xs);
2258- color: var(--color-text-muted);
2259- }
2260- .release-item-date {
2261- display: flex;
2262- flex-direction: column;
2263- align-items: flex-end;
2264- gap: var(--space-2);
2265- font-size: var(--text-xs);
2266- color: var(--color-text-muted);
2267- white-space: nowrap;
2268- flex-shrink: 0;
2269- }
2270-
2271- /* --- Release notes preview in list --- */
2272- .release-notes-section {
2273- margin-top: var(--space-3);
2274- }
2275- .release-notes-label {
2276- font-size: var(--text-xs);
2277- font-weight: 600;
2278- color: var(--color-text-muted);
2279- text-transform: uppercase;
2280- letter-spacing: 0.05em;
2281- margin-bottom: var(--space-2);
2282- }
2283- .notes-expand > summary {
2284- list-style: none;
2285- cursor: pointer;
2286- outline: none;
2287- }
2288- .notes-expand > summary::-webkit-details-marker {
2289- display: none;
2290- }
2291- .notes-preview {
2292- max-height: calc(5 * 1.6em);
2293- overflow: hidden;
2294- position: relative;
2295- white-space: pre-wrap;
2296- font-size: var(--text-sm);
2297- color: var(--color-text);
2298- line-height: 1.6;
2299- word-break: break-word;
2300- margin-bottom: var(--space-1);
2301- }
2302- .notes-preview::after {
2303- content: "";
2304- position: absolute;
2305- bottom: 0;
2306- left: 0;
2307- right: 0;
2308- height: 2.5em;
2309- background: linear-gradient(transparent, var(--color-bg));
2310- pointer-events: none;
2311- }
2312- .notes-expand[open] .notes-preview {
2313- display: none;
2314- }
2315- .notes-full {
2316- padding-top: var(--space-3);
2317- padding-bottom: var(--space-1);
2318- }
2319- .notes-expand[open] {
2320- display: flex;
2321- flex-direction: column;
2322- }
2323- .notes-expand[open] > summary {
2324- order: 1;
2325- }
2326- .notes-expand[open] > .notes-full {
2327- order: 0;
2328- }
2329- .notes-toggle-label {
2330- display: inline-block;
2331- font-size: var(--text-xs);
2332- color: var(--color-link);
2333- margin-top: var(--space-1);
2334- }
2335- .notes-toggle-label:hover {
2336- text-decoration: underline;
2337- }
2338- .notes-toggle-label::before {
2339- content: "Show more ↓";
2340- }
2341- .notes-expand[open] .notes-toggle-label::before {
2342- content: "Show less ↑";
2343- }
2344-
2345- /* --- Release detail --- */
2346- .release-detail {
2347- padding: var(--space-6) 0;
2348- }
2349- .release-header {
2350- margin-bottom: var(--space-6);
2351- }
2352- .release-notes {
2353- margin-bottom: var(--space-6);
2354- }
2355- .release-assets {
2356- margin-bottom: var(--space-6);
2357- }
2358- .release-assets-heading {
2359- font-size: var(--text-base);
2360- font-weight: 600;
2361- margin-bottom: var(--space-3);
2362- }
2363- .asset-list {
2364- border: 1px solid var(--color-border);
2365- border-radius: var(--radius-lg);
2366- overflow: hidden;
2367- }
2368- .asset-item {
2369- display: flex;
2370- align-items: center;
2371- gap: var(--space-4);
2372- padding: var(--space-3) var(--space-4);
2373- border-bottom: 1px solid var(--color-border-muted);
2374- font-size: var(--text-sm);
2375- }
2376- .asset-item:last-child {
2377- border-bottom: none;
2378- }
2379- .asset-name {
2380- flex: 1;
2381- color: var(--color-link);
2382- text-decoration: none;
2383- font-family: var(--font-mono);
2384- font-size: var(--text-xs);
2385- }
2386- .asset-name:hover {
2387- text-decoration: underline;
2388- }
2389- .asset-name-pending {
2390- color: var(--color-text-muted);
2391- cursor: default;
2392- }
2393- .asset-item-pending {
2394- opacity: 0.6;
2395- }
2396- .asset-size,
2397- .asset-meta {
2398- color: var(--color-text-muted);
2399- font-size: var(--text-xs);
2400- white-space: nowrap;
2401- }
2402- .release-back {
2403- margin-top: var(--space-4);
2404- display: flex;
2405- gap: var(--space-3);
2406- align-items: center;
2407- justify-content: space-between;
2408- }
2409-}
2410-
2411-/* ============================================================
2412- @layer utilities
2413- ============================================================ */
2414-@layer utilities {
2415- .text-muted {
2416- color: var(--color-text-muted);
2417- }
2418- .text-sm {
2419- font-size: var(--text-sm);
2420- }
2421- .text-xs {
2422- font-size: var(--text-xs);
2423- }
2424- .font-mono {
2425- font-family: var(--font-mono);
2426- }
2427- .mt-1 {
2428- margin-top: var(--space-1);
2429- }
2430- .mt-2 {
2431- margin-top: var(--space-2);
2432- }
2433- .mt-4 {
2434- margin-top: var(--space-4);
2435- }
2436- .mt-6 {
2437- margin-top: var(--space-6);
2438- }
2439- .mb-4 {
2440- margin-bottom: var(--space-4);
2441- }
2442- .flex {
2443- display: flex;
2444- }
2445- .gap-2 {
2446- gap: var(--space-2);
2447- }
2448- .gap-4 {
2449- gap: var(--space-4);
2450- }
2451- .items-center {
2452- align-items: center;
2453- }
2454- .justify-between {
2455- justify-content: space-between;
2456- }
2457- .hidden {
2458- display: none;
2459- }
2460-}
2461-
2462-/* ============================================================
2463- Pagination
2464- ============================================================ */
2465-.commit-verify-hint {
2466- margin-bottom: var(--space-4);
2467- font-size: var(--text-xs);
2468- color: var(--color-text-muted);
2469-}
2470-.commit-verify-hint code {
2471- word-break: break-all;
2472-}
2473-.commit-cursor-nav {
2474- display: flex;
2475- justify-content: space-between;
2476- border-top: 1px solid var(--color-border);
2477- margin-top: var(--space-2);
2478- padding: var(--space-4) 0 var(--space-2);
2479-}
2480-
2481-.commit-cursor-prev {
2482- flex: 1;
2483-}
2484-.commit-cursor-next {
2485- flex: 1;
2486- text-align: right;
2487-}
2488-
2489-.pagination {
2490- display: grid;
2491- grid-template-columns: 1fr auto 1fr;
2492- align-items: center;
2493- border-top: 1px solid var(--color-border);
2494- margin-top: var(--space-2);
2495- padding: var(--space-4) 0 var(--space-2);
2496-}
2497-
2498-.pagination-prev {
2499- justify-self: start;
2500-}
2501-.pagination-next {
2502- justify-self: end;
2503-}
2504-
2505-.pagination-info {
2506- justify-self: center;
2507- font-size: var(--text-sm);
2508- color: var(--color-text-muted);
2509-}
2510-
2511-.pagination-btn {
2512- display: inline-flex;
2513- align-items: center;
2514- gap: var(--space-1);
2515- padding: var(--space-1) var(--space-3);
2516- border: 1px solid var(--color-border);
2517- border-radius: var(--radius-md);
2518- font-size: var(--text-sm);
2519- font-weight: 500;
2520- text-decoration: none;
2521- color: var(--color-text);
2522- background: var(--color-bg-subtle);
2523- transition:
2524- background 0.1s,
2525- border-color 0.1s;
2526- white-space: nowrap;
2527-}
2528-
2529-.pagination-btn:hover {
2530- background: var(--color-bg-inset);
2531- border-color: var(--color-border);
2532- color: var(--color-text);
2533- text-decoration: none;
2534-}
2535-
2536-/* Settings */
2537-.theme-options {
2538- display: flex;
2539- gap: var(--space-4);
2540- margin-bottom: var(--space-4);
2541- flex-wrap: wrap;
2542-}
2543-.theme-option {
2544- display: flex;
2545- align-items: center;
2546- gap: var(--space-2);
2547- cursor: pointer;
2548- font-size: var(--text-sm);
2549-}
2550-.settings-form {
2551- display: flex;
2552- flex-direction: column;
2553- gap: var(--space-4);
2554-}
2555-.passkey-list {
2556- display: flex;
2557- flex-direction: column;
2558- gap: var(--space-2);
2559- margin-bottom: var(--space-4);
2560-}
2561-.passkey-item {
2562- display: flex;
2563- align-items: center;
2564- gap: var(--space-3);
2565- padding: var(--space-2) 0;
2566- border-bottom: 1px solid var(--color-border-muted);
2567- font-size: var(--text-sm);
2568-}
2569-.passkey-item:last-child {
2570- border-bottom: none;
2571-}
2572-.passkey-date {
2573- flex: 1;
2574- color: var(--color-text-muted);
2575-}
2576-.queue-bulk-actions {
2577- display: flex;
2578- gap: var(--space-2);
2579- margin-bottom: var(--space-4);
2580-}
2581-.queue-list {
2582- list-style: none;
2583- padding: 0;
2584- margin: 0;
2585- max-height: 32rem;
2586- overflow-y: auto;
2587-}
2588-.queue-item {
2589- display: flex;
2590- align-items: flex-start;
2591- justify-content: space-between;
2592- gap: var(--space-4);
2593- padding: var(--space-3) 0;
2594- border-top: 1px solid var(--color-border-muted);
2595- font-size: var(--text-sm);
2596-}
2597-.queue-item-meta {
2598- display: flex;
2599- flex-direction: column;
2600- gap: var(--space-1);
2601- min-width: 0;
2602-}
2603-.queue-item-header {
2604- display: flex;
2605- align-items: baseline;
2606- gap: var(--space-2);
2607-}
2608-.queue-item-date {
2609- color: var(--color-text-muted);
2610- font-size: var(--text-xs);
2611-}
2612-.queue-item-answer {
2613- margin: 0;
2614- color: var(--color-text-muted);
2615- font-size: var(--text-xs);
2616- white-space: pre-wrap;
2617- word-break: break-word;
2618-}
2619-.queue-item-actions {
2620- display: flex;
2621- gap: var(--space-2);
2622- flex-shrink: 0;
2623-}
2624-.ssh-key-info {
2625- flex: 1;
2626- display: flex;
2627- flex-direction: column;
2628- gap: var(--space-1);
2629- min-width: 0;
2630-}
2631-.ssh-key-name {
2632- font-weight: 500;
2633-}
2634-.ssh-key-fingerprint {
2635- font-family: var(--font-mono);
2636- font-size: var(--text-xs);
2637- overflow: hidden;
2638- text-overflow: ellipsis;
2639- white-space: nowrap;
2640-}
2641-.form-textarea {
2642- resize: vertical;
2643- min-height: 4.5rem;
2644- font-family: var(--font-mono);
2645- font-size: var(--text-sm);
2646-}
2647-/* Clone popup */
2648-.clone-popup-details {
2649- position: relative;
2650- display: inline-block;
2651-}
2652-.clone-popup-details > summary {
2653- list-style: none;
2654-}
2655-.clone-popup-details > summary::-webkit-details-marker {
2656- display: none;
2657-}
2658-.clone-popup {
2659- position: absolute;
2660- right: 1rem;
2661- top: calc(100% + var(--space-2));
2662- background: var(--color-bg);
2663- border: 1px solid var(--color-border);
2664- border-radius: var(--radius-lg);
2665- padding: var(--space-3);
2666- min-width: 22rem;
2667- z-index: 100;
2668- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2669- display: flex;
2670- flex-direction: column;
2671- gap: var(--space-2);
2672-}
2673-.clone-url-row {
2674- display: flex;
2675- align-items: center;
2676- gap: var(--space-2);
2677-}
2678-.clone-url-label {
2679- font-size: var(--text-xs);
2680- font-weight: 600;
2681- color: var(--color-text-muted);
2682- text-transform: uppercase;
2683- letter-spacing: 0.05em;
2684- min-width: 2.75rem;
2685-}
2686-.clone-url-input {
2687- flex: 1;
2688- font-family: var(--font-mono);
2689- font-size: var(--text-sm);
2690- background: var(--color-bg-subtle);
2691- border: 1px solid var(--color-border-muted);
2692- border-radius: var(--radius-md);
2693- padding: var(--space-1) var(--space-2);
2694- color: var(--color-text);
2695- cursor: text;
2696- min-width: 0;
2697-}
2698-.user-list {
2699- display: flex;
2700- flex-direction: column;
2701- gap: var(--space-2);
2702- margin-bottom: var(--space-6);
2703-}
2704-.user-item {
2705- display: flex;
2706- align-items: center;
2707- gap: var(--space-3);
2708- padding: var(--space-2) 0;
2709- border-bottom: 1px solid var(--color-border-muted);
2710- font-size: var(--text-sm);
2711-}
2712-.user-item:last-child {
2713- border-bottom: none;
2714-}
2715-.user-name {
2716- font-weight: 500;
2717- flex: 1;
2718-}
2719-.user-date {
2720- color: var(--color-text-muted);
2721- font-size: var(--text-xs);
2722-}
2723-
2724-/* Labels */
2725-.label-badge {
2726- display: inline-block;
2727- padding: 0 var(--space-2);
2728- border-radius: 2em;
2729- font-size: var(--text-xs);
2730- font-weight: 500;
2731- line-height: 1.6;
2732- word-break: break-all;
2733-}
2734-.issue-labels {
2735- display: flex;
2736- flex-wrap: wrap;
2737- gap: var(--space-1);
2738- margin-bottom: var(--space-2);
2739-}
2740-.issue-labels-row {
2741- display: flex;
2742- flex-wrap: wrap;
2743- align-items: center;
2744- gap: var(--space-2);
2745- padding: var(--space-2) 0 var(--space-3);
2746-}
2747-.label-badge-wrap {
2748- display: inline-flex;
2749- align-items: center;
2750- gap: var(--space-1);
2751-}
2752-.label-remove-form {
2753- display: inline;
2754-}
2755-.label-remove-btn {
2756- background: none;
2757- border: none;
2758- cursor: pointer;
2759- padding: 0 2px;
2760- font-size: var(--text-sm);
2761- line-height: 1;
2762- color: var(--color-text-muted);
2763- opacity: 0.7;
2764-}
2765-.label-remove-btn:hover {
2766- opacity: 1;
2767-}
2768-.label-add-inline-form {
2769- display: inline-flex;
2770- align-items: center;
2771- gap: var(--space-2);
2772-}
2773-.label-select {
2774- font-size: var(--text-sm);
2775- padding: var(--space-1) var(--space-2);
2776- border: 1px solid var(--color-border);
2777- border-radius: var(--radius-md);
2778- background: var(--color-bg);
2779- color: var(--color-text);
2780- height: 2rem;
2781-}
2782-.label-checkbox-list {
2783- display: flex;
2784- flex-wrap: wrap;
2785- gap: var(--space-2);
2786- margin-top: var(--space-1);
2787-}
2788-.label-checkbox-item {
2789- display: inline-flex;
2790- align-items: center;
2791- gap: var(--space-1);
2792- cursor: pointer;
2793-}
2794-.label-settings-list {
2795- display: flex;
2796- flex-direction: column;
2797- margin-bottom: var(--space-4);
2798- max-height: 24rem;
2799- overflow-y: auto;
2800-}
2801-.label-settings-item {
2802- display: flex;
2803- align-items: center;
2804- gap: var(--space-3);
2805- padding: var(--space-2) 0;
2806- border-bottom: 1px solid var(--color-border-muted);
2807- font-size: var(--text-sm);
2808-}
2809-.label-settings-item:last-child {
2810- border-bottom: none;
2811-}
2812-.label-settings-swatch {
2813- width: 12px;
2814- height: 12px;
2815- border-radius: 50%;
2816- flex-shrink: 0;
2817- border: 1px solid rgba(0, 0, 0, 0.15);
2818-}
2819-.label-settings-name {
2820- flex: 1;
2821- font-weight: 500;
2822-}
2823-.label-add-form {
2824- display: flex;
2825- align-items: center;
2826- gap: var(--space-2);
2827-}
2828-.label-name-input {
2829- flex: 1;
2830- min-width: 0;
2831- padding: var(--space-2) var(--space-3);
2832- border: 1px solid var(--color-border);
2833- border-radius: var(--radius-md);
2834- background: var(--color-bg);
2835- color: var(--color-text);
2836- font-size: var(--text-sm);
2837- height: 2rem;
2838- box-sizing: border-box;
2839-}
2840-.label-name-input:focus {
2841- outline: 2px solid var(--color-accent);
2842- outline-offset: -1px;
2843- border-color: var(--color-accent);
2844-}
2845-.label-color-swatch-label {
2846- flex-shrink: 0;
2847- cursor: pointer;
2848- display: block;
2849- line-height: 0;
2850-}
2851-.label-color-input {
2852- display: block;
2853- width: 2rem;
2854- height: 2rem;
2855- padding: 2px;
2856- border: 1px solid var(--color-border);
2857- border-radius: var(--radius-md);
2858- background: var(--color-bg);
2859- cursor: pointer;
2860- -webkit-appearance: none;
2861- appearance: none;
2862- box-sizing: border-box;
2863-}
2864-
2865-/* Label filter (details/summary popup) */
2866-.list-header-actions {
2867- display: flex;
2868- align-items: center;
2869- gap: var(--space-2);
2870- flex-shrink: 0;
2871-}
2872-.label-filter {
2873- position: relative;
2874-}
2875-.label-filter-popup {
2876- position: absolute;
2877- right: 0;
2878- top: calc(100% + var(--space-1));
2879- z-index: 10;
2880- background: var(--color-bg);
2881- border: 1px solid var(--color-border);
2882- border-radius: var(--radius-lg);
2883- padding: var(--space-3);
2884- min-width: 180px;
2885- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
2886-}
2887-.label-filter-list {
2888- display: flex;
2889- flex-direction: column;
2890- gap: var(--space-2);
2891- margin-bottom: var(--space-3);
2892- max-height: 16rem;
2893- overflow-y: auto;
2894-}
2895-.label-filter-item {
2896- display: flex;
2897- align-items: center;
2898- gap: var(--space-2);
2899- cursor: pointer;
2900- font-size: var(--text-sm);
2901-}
2902-.label-filter-actions {
2903- display: flex;
2904- gap: var(--space-2);
2905-}
4+@import "./base.css";
5+@import "./components.css";
6+@import "./code.css";
7+@import "./utilities.css";
Asrc/styles/utilities.css
@@ -0,0 +1,47 @@
1+@layer utilities {
2+ .text-muted {
3+ color: var(--color-text-muted);
4+ }
5+ .text-sm {
6+ font-size: var(--text-sm);
7+ }
8+ .text-xs {
9+ font-size: var(--text-xs);
10+ }
11+ .font-mono {
12+ font-family: var(--font-mono);
13+ }
14+ .mt-1 {
15+ margin-top: var(--space-1);
16+ }
17+ .mt-2 {
18+ margin-top: var(--space-2);
19+ }
20+ .mt-4 {
21+ margin-top: var(--space-4);
22+ }
23+ .mt-6 {
24+ margin-top: var(--space-6);
25+ }
26+ .mb-4 {
27+ margin-bottom: var(--space-4);
28+ }
29+ .flex {
30+ display: flex;
31+ }
32+ .gap-2 {
33+ gap: var(--space-2);
34+ }
35+ .gap-4 {
36+ gap: var(--space-4);
37+ }
38+ .items-center {
39+ align-items: center;
40+ }
41+ .justify-between {
42+ justify-content: space-between;
43+ }
44+ .hidden {
45+ display: none;
46+ }
47+}
Msrc/views/layout.tsx
@@ -1,4 +1,4 @@
1-import { OWNER_DISPLAY_NAME } from "../config.ts";
1+import config from "../config.ts";
22 import { displayName } from "../lib/users.ts";
33 import type { SessionUser } from "../middleware/session.ts";
44 import { Avatar } from "./Avatar.tsx";
@@ -80,7 +80,7 @@ export function Layout({ user, title, children }: LayoutProps) {
8080 </header>
8181 <main class="site-main">{children}</main>
8282 <footer class="site-footer">
83- <p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p>
83+ <p>Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}</p>
8484 </footer>
8585 </body>
8686 </html>
Msrc/views/repos/RepoHome.tsx
@@ -1,4 +1,4 @@
1-import { BASE_URL, SSH_DISABLED, SSH_PORT } from "../../config.ts";
1+import config from "../../config.ts";
22 import type { RepositoryRow } from "../../db/index.ts";
33 import type { SessionUser } from "../../middleware/session.ts";
44 import type { TreeEntry } from "../../services/git.ts";
@@ -73,7 +73,7 @@ git push origin main`}</code>
7373 class="clone-url-input"
7474 />
7575 </div>
76- {!SSH_DISABLED && (
76+ {!config.SSH_DISABLED && (
7777 <div class="clone-url-row">
7878 <span class="clone-url-label">
7979 SSH
@@ -119,10 +119,10 @@ git push origin main`}</code>
119119 }
120120
121121 function httpUrl(name: string) {
122- return `${BASE_URL}/${name}.git`;
122+ return `${config.BASE_URL}/${name}.git`;
123123 }
124124
125125 function sshUrl(name: string) {
126- const host = new URL(BASE_URL).hostname;
127- return `ssh://git@${host}:${SSH_PORT}/${name}.git`;
126+ const host = new URL(config.BASE_URL).hostname;
127+ return `ssh://git@${host}:${config.SSH_PORT}/${name}.git`;
128128 }
Atests/e2e.auth.test.ts
@@ -0,0 +1,112 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ logout,
12+} from './helpers.ts';
13+
14+let browser: Browser;
15+let server: Awaited<ReturnType<typeof spawnServer>>;
16+
17+beforeAll(async () => {
18+ await setupTestEnv();
19+ server = await spawnServer();
20+ browser = await chromium.launch();
21+});
22+
23+afterAll(async () => {
24+ await browser.close();
25+ await killServer(server);
26+});
27+
28+// ─── Auth ─────────────────────────────────────────────────────────────────────
29+
30+describe('auth', () => {
31+ test('homepage loads', async () => {
32+ const ctx = await browser.newContext();
33+ const page = await ctx.newPage();
34+ try {
35+ await page.goto(BASE);
36+ expect(await page.title()).toContain('Hearthforge');
37+ } finally { await ctx.close(); }
38+ });
39+
40+ test('wrong password shows error', async () => {
41+ const ctx = await browser.newContext();
42+ const page = await ctx.newPage();
43+ try {
44+ await page.goto(`${BASE}/login`);
45+ await page.fill('[name=username]', 'admin');
46+ await page.fill('[name=password]', 'wrongpassword');
47+ await page.click('button[type=submit]');
48+ expect(await page.locator('.form-error').textContent()).toContain('Invalid');
49+ } finally { await ctx.close(); }
50+ });
51+
52+ test('correct credentials redirect to homepage', async () => {
53+ const ctx = await browser.newContext();
54+ const page = await ctx.newPage();
55+ try {
56+ await login(page);
57+ expect(page.url()).toBe(BASE + '/');
58+ expect(await page.locator('.nav-user').isVisible()).toBe(true);
59+ } finally { await ctx.close(); }
60+ });
61+
62+ test('register new user', async () => {
63+ const ctx = await browser.newContext();
64+ const page = await ctx.newPage();
65+ try {
66+ await page.goto(`${BASE}/register`);
67+ await page.fill('[name=username]', 'alice');
68+ await page.fill('[name=password]', 'password123');
69+ await page.fill('[name=password2]', 'password123');
70+ await page.click('button[type=submit]');
71+ await page.waitForURL(BASE + '/');
72+ expect(await page.locator('.nav-user').textContent()).toBe('alice');
73+ } finally { await ctx.close(); }
74+ });
75+
76+ test('register with mismatched passwords shows error', async () => {
77+ const ctx = await browser.newContext();
78+ const page = await ctx.newPage();
79+ try {
80+ await page.goto(`${BASE}/register`);
81+ await page.fill('[name=username]', 'bob');
82+ await page.fill('[name=password]', 'password123');
83+ await page.fill('[name=password2]', 'different456');
84+ await page.click('button[type=submit]');
85+ expect(await page.locator('.form-error').textContent()).toContain('match');
86+ } finally { await ctx.close(); }
87+ });
88+
89+ test('register with duplicate username shows error', async () => {
90+ const ctx = await browser.newContext();
91+ const page = await ctx.newPage();
92+ try {
93+ await page.goto(`${BASE}/register`);
94+ await page.fill('[name=username]', 'alice'); // already registered above
95+ await page.fill('[name=password]', 'password123');
96+ await page.fill('[name=password2]', 'password123');
97+ await page.click('button[type=submit]');
98+ expect(await page.locator('.form-error').textContent()).toContain('taken');
99+ } finally { await ctx.close(); }
100+ });
101+
102+ test('logout clears session', async () => {
103+ const ctx = await browser.newContext();
104+ const page = await ctx.newPage();
105+ try {
106+ await login(page);
107+ await logout(page);
108+ expect(await page.locator('.nav-user').count()).toBe(0);
109+ expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
110+ } finally { await ctx.close(); }
111+ });
112+});
Atests/e2e.file-editing.test.ts
@@ -0,0 +1,157 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ DATA_DIR,
7+ ADMIN_PASS,
8+ setupTestEnv,
9+ spawnServer,
10+ killServer,
11+ login,
12+ seedRepo,
13+ getHeadCommit,
14+ gitOutput,
15+} from './helpers.ts';
16+
17+let browser: Browser;
18+let server: Awaited<ReturnType<typeof spawnServer>>;
19+
20+beforeAll(async () => {
21+ await setupTestEnv();
22+ server = await spawnServer();
23+ browser = await chromium.launch();
24+});
25+
26+afterAll(async () => {
27+ await browser.close();
28+ await killServer(server);
29+});
30+
31+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
32+ const ctx = await browser.newContext();
33+ const page = await ctx.newPage();
34+ await login(page, username, password);
35+ await page.close();
36+ return ctx;
37+}
38+
39+// ─── File editing ─────────────────────────────────────────────────────────────
40+
41+describe('file editing', () => {
42+ let adminCtx: BrowserContext;
43+
44+ beforeAll(async () => {
45+ adminCtx = await loggedInContext();
46+ // Create a dedicated repo so edits don't interfere with other tests
47+ const page = await adminCtx.newPage();
48+ try {
49+ await page.goto(`${BASE}/new`);
50+ await page.fill('[name=name]', 'edit-repo');
51+ await page.click('form[action="/new"] button[type=submit]');
52+ await page.waitForURL(`${BASE}/edit-repo`);
53+ } finally { await page.close(); }
54+ await seedRepo('edit-repo');
55+ });
56+
57+ afterAll(async () => { await adminCtx.close(); });
58+
59+ test('Edit button appears on text file blob when viewing a branch as admin', async () => {
60+ const page = await adminCtx.newPage();
61+ try {
62+ await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
63+ const editBtn = page.locator('a[href*="/edit/main/index.js"]');
64+ expect(await editBtn.isVisible()).toBe(true);
65+ expect(await editBtn.textContent()).toBe('Edit');
66+ } finally { await page.close(); }
67+ });
68+
69+ test('Edit button does not appear when viewing a commit SHA', async () => {
70+ const sha = await getHeadCommit('edit-repo');
71+ const page = await adminCtx.newPage();
72+ try {
73+ await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
74+ expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
75+ } finally { await page.close(); }
76+ });
77+
78+ test('Edit button does not appear for unauthenticated visitors', async () => {
79+ const ctx = await browser.newContext();
80+ const page = await ctx.newPage();
81+ try {
82+ await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
83+ expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
84+ } finally {
85+ await page.close();
86+ await ctx.close();
87+ }
88+ });
89+
90+ test('edit page loads with file content pre-filled', async () => {
91+ const page = await adminCtx.newPage();
92+ try {
93+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
94+ expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
95+ const content = await page.locator('textarea[name=content]').inputValue();
96+ expect(content).toContain('hello');
97+ const msg = await page.locator('textarea[name=message]').inputValue();
98+ expect(msg).toBe('Edited index.js');
99+ } finally { await page.close(); }
100+ });
101+
102+ test('edit page shows which branch will be committed to', async () => {
103+ const page = await adminCtx.newPage();
104+ try {
105+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
106+ expect(await page.content()).toContain('main');
107+ } finally { await page.close(); }
108+ });
109+
110+ test('edit page returns 404 for non-branch ref', async () => {
111+ const sha = await getHeadCommit('edit-repo');
112+ const page = await adminCtx.newPage();
113+ try {
114+ const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
115+ expect(resp.status()).toBe(404);
116+ } finally { await page.close(); }
117+ });
118+
119+ test('submitting edit creates a new commit and redirects to blob view', async () => {
120+ const page = await adminCtx.newPage();
121+ try {
122+ await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
123+ await page.fill('textarea[name=content]', 'console.log("edited");\n');
124+ await page.fill('textarea[name=message]', 'Update index.js via web editor');
125+ await page.locator('.form-actions button[type=submit]').click();
126+ await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
127+ // The commit detail view should show the commit message
128+ expect(await page.content()).toContain('Update index.js via web editor');
129+ } finally { await page.close(); }
130+ });
131+
132+ test('edit commit has a gpgsig header (is signed)', async () => {
133+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
134+ const hash = gitOutput(['log', '--format=%H', '--grep=Update index.js via web editor', '-1'], repoDir);
135+ expect(hash).toBeTruthy();
136+ const obj = gitOutput(['cat-file', '-p', hash], repoDir);
137+ expect(obj).toContain('gpgsig');
138+ });
139+
140+ test('edit commit shows verified badge in commit log', async () => {
141+ const page = await adminCtx.newPage();
142+ try {
143+ await page.goto(`${BASE}/edit-repo/commits/main`);
144+ const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
145+ expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
146+ } finally { await page.close(); }
147+ });
148+
149+ test('GET edit page returns 404 for non-branch ref', async () => {
150+ const sha = await getHeadCommit('edit-repo');
151+ const page = await adminCtx.newPage();
152+ try {
153+ const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
154+ expect(resp.status()).toBe(404);
155+ } finally { await page.close(); }
156+ });
157+});
Atests/e2e.issues.test.ts
@@ -0,0 +1,446 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ seedRepo,
12+} from './helpers.ts';
13+
14+let browser: Browser;
15+let server: Awaited<ReturnType<typeof spawnServer>>;
16+
17+beforeAll(async () => {
18+ await setupTestEnv();
19+ server = await spawnServer();
20+ browser = await chromium.launch();
21+
22+ // Register alice
23+ const regCtx = await browser.newContext();
24+ const regPage = await regCtx.newPage();
25+ try {
26+ await regPage.goto(`${BASE}/register`);
27+ await regPage.fill('[name=username]', 'alice');
28+ await regPage.fill('[name=password]', 'password123');
29+ await regPage.fill('[name=password2]', 'password123');
30+ await regPage.click('button[type=submit]');
31+ await regPage.waitForURL(BASE + '/');
32+ } finally { await regCtx.close(); }
33+
34+ // Create my-repo
35+ const adminCtx = await browser.newContext();
36+ const adminPage = await adminCtx.newPage();
37+ try {
38+ await login(adminPage);
39+ await adminPage.goto(`${BASE}/new`);
40+ await adminPage.fill('[name=name]', 'my-repo');
41+ await adminPage.click('form[action="/new"] button[type=submit]');
42+ await adminPage.waitForURL(`${BASE}/my-repo`);
43+ } finally { await adminCtx.close(); }
44+
45+ await seedRepo('my-repo');
46+});
47+
48+afterAll(async () => {
49+ await browser.close();
50+ await killServer(server);
51+});
52+
53+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
54+ const ctx = await browser.newContext();
55+ const page = await ctx.newPage();
56+ await login(page, username, password);
57+ await page.close();
58+ return ctx;
59+}
60+
61+// ─── Issues ───────────────────────────────────────────────────────────────────
62+
63+describe('issues', () => {
64+ let adminCtx: BrowserContext;
65+ let issueUrl: string;
66+ let completedIssueUrl: string;
67+
68+ beforeAll(async () => {
69+ adminCtx = await loggedInContext();
70+ });
71+
72+ afterAll(async () => { await adminCtx.close(); });
73+
74+ test('create issue', async () => {
75+ const page = await adminCtx.newPage();
76+ try {
77+ await page.goto(`${BASE}/my-repo/issues/new`);
78+ await page.fill('[name=title]', 'First issue');
79+ await page.fill('[name=body]', 'Body with **markdown**.');
80+ await page.click('form[action$="/issues"] button[type=submit]');
81+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
82+ issueUrl = page.url();
83+ expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
84+ } finally { await page.close(); }
85+ });
86+
87+ test('issue body renders markdown', async () => {
88+ const page = await adminCtx.newPage();
89+ try {
90+ await page.goto(issueUrl);
91+ expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
92+ } finally { await page.close(); }
93+ });
94+
95+ test('issue appears in open list', async () => {
96+ const page = await adminCtx.newPage();
97+ try {
98+ await page.goto(`${BASE}/my-repo/issues`);
99+ const titles = await page.locator('.issue-title').allTextContents();
100+ expect(titles.some(t => t.includes('First issue'))).toBe(true);
101+ } finally { await page.close(); }
102+ });
103+
104+ test('unauthenticated user is redirected to login from new issue form', async () => {
105+ const ctx = await browser.newContext();
106+ const page = await ctx.newPage();
107+ try {
108+ await page.goto(`${BASE}/my-repo/issues/new`);
109+ expect(page.url()).toContain('/login');
110+ } finally { await ctx.close(); }
111+ });
112+
113+ test('add comment', async () => {
114+ const page = await adminCtx.newPage();
115+ try {
116+ await page.goto(issueUrl);
117+ const beforeCount = await page.locator('.timeline-item').count();
118+ await page.fill('textarea[name=body]', 'A follow-up comment.');
119+ await page.click('form[action*="/comments"] button[type=submit]');
120+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
121+ expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
122+ } finally { await page.close(); }
123+ });
124+
125+ test('react to issue', async () => {
126+ const page = await adminCtx.newPage();
127+ try {
128+ await page.goto(issueUrl);
129+ await page.locator('.reaction-picker').first().click();
130+ await page.locator('.reaction-picker-btn').first().click();
131+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
132+ expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
133+ } finally { await page.close(); }
134+ });
135+
136+ test('close issue changes status badge', async () => {
137+ const page = await adminCtx.newPage();
138+ try {
139+ await page.goto(issueUrl);
140+ await page.click('form[action*="/close"] button');
141+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
142+ expect(await page.locator('.issue-badge').textContent()).toBe('closed');
143+ } finally { await page.close(); }
144+ });
145+
146+ test('closed issue appears in closed list', async () => {
147+ const page = await adminCtx.newPage();
148+ try {
149+ await page.goto(`${BASE}/my-repo/issues?status=closed`);
150+ const titles = await page.locator('.issue-title').allTextContents();
151+ expect(titles.some(t => t.includes('First issue'))).toBe(true);
152+ } finally { await page.close(); }
153+ });
154+
155+ test('reopen issue', async () => {
156+ const page = await adminCtx.newPage();
157+ try {
158+ await page.goto(issueUrl);
159+ await page.click('.issue-detail-meta-actions button');
160+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
161+ expect(await page.locator('.issue-badge').textContent()).toBe('open');
162+ } finally { await page.close(); }
163+ });
164+
165+ test('completed button marks issue as completed', async () => {
166+ const page = await adminCtx.newPage();
167+ try {
168+ await page.goto(`${BASE}/my-repo/issues/new`);
169+ await page.fill('[name=title]', 'To be completed');
170+ await page.click('form[action$="/issues"] button[type=submit]');
171+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
172+ completedIssueUrl = page.url();
173+ await page.click('form[action*="/complete"] button');
174+ await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
175+ expect(await page.locator('.issue-badge').textContent()).toBe('completed');
176+ } finally { await page.close(); }
177+ });
178+
179+ test('completed issue appears in completed list', async () => {
180+ const page = await adminCtx.newPage();
181+ try {
182+ await page.goto(`${BASE}/my-repo/issues?status=completed`);
183+ const titles = await page.locator('.issue-title').allTextContents();
184+ expect(titles.some(t => t.includes('To be completed'))).toBe(true);
185+ } finally { await page.close(); }
186+ });
187+
188+ test('non-admin cannot complete or close issue', async () => {
189+ const issueNum = issueUrl.split('/issues/')[1];
190+ const ctx = await browser.newContext();
191+ try {
192+ const completeResp = await ctx.request.post(
193+ `${BASE}/my-repo/issues/${issueNum}/complete`,
194+ { maxRedirects: 0 },
195+ );
196+ expect(completeResp.status()).toBe(302);
197+ expect(completeResp.headers()['location']).toContain('/login');
198+ } finally { await ctx.close(); }
199+ });
200+
201+ test('reacting with same emoji toggles it off', async () => {
202+ const page = await adminCtx.newPage();
203+ try {
204+ await page.goto(issueUrl);
205+ // Reaction was added by the earlier 'react to issue' test
206+ expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
207+ await page.locator('.reaction-btn').first().click();
208+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
209+ expect(await page.locator('.reaction-btn').count()).toBe(0);
210+ } finally { await page.close(); }
211+ });
212+
213+ test('react to issue comment', async () => {
214+ const page = await adminCtx.newPage();
215+ try {
216+ await page.goto(issueUrl);
217+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
218+ .filter({ hasText: 'A follow-up comment.' });
219+ await commentItem.locator('.reaction-add-btn').click();
220+ await commentItem.locator('.reaction-picker-btn').first().click();
221+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
222+ expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
223+ } finally { await page.close(); }
224+ });
225+});
226+
227+// ─── Issue editing and deletion ───────────────────────────────────────────────
228+
229+describe('issue editing', () => {
230+ let adminCtx: BrowserContext;
231+ let aliceCtx: BrowserContext;
232+ let issueUrl: string;
233+
234+ beforeAll(async () => {
235+ adminCtx = await loggedInContext();
236+ aliceCtx = await loggedInContext('alice', 'password123');
237+
238+ // Create an issue to edit
239+ const page = await adminCtx.newPage();
240+ try {
241+ await page.goto(`${BASE}/my-repo/issues/new`);
242+ await page.fill('[name=title]', 'Issue to edit');
243+ await page.fill('[name=body]', 'Original body.');
244+ await page.click('form[action$="/issues"] button[type=submit]');
245+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
246+ issueUrl = page.url();
247+ } finally { await page.close(); }
248+ });
249+
250+ afterAll(async () => {
251+ await adminCtx.close();
252+ await aliceCtx.close();
253+ });
254+
255+ test('author can edit issue title and body', async () => {
256+ const page = await adminCtx.newPage();
257+ try {
258+ await page.goto(issueUrl);
259+ // Edit title via title form
260+ await page.click('details.title-edit-details summary');
261+ await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
262+ await page.click('.title-edit-form-area [type=submit]');
263+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
264+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
265+ // Edit body via inline form
266+ await page.click('.timeline-author .inline-edit-details summary');
267+ await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
268+ await page.click('.inline-edit-form-area [type=submit]');
269+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
270+ } finally { await page.close(); }
271+ });
272+
273+ test('edited marker appears after editing', async () => {
274+ const page = await adminCtx.newPage();
275+ try {
276+ await page.goto(issueUrl);
277+ expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
278+ } finally { await page.close(); }
279+ });
280+
281+ test('non-author non-admin cannot edit issue', async () => {
282+ const page = await aliceCtx.newPage();
283+ try {
284+ const issueNum = issueUrl.split('/issues/')[1];
285+ const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
286+ form: { title: 'Hacked title', edit_body: '' },
287+ maxRedirects: 0,
288+ });
289+ expect(resp.status()).toBe(403);
290+ } finally { await page.close(); }
291+ });
292+
293+ test('author can edit issue comment', async () => {
294+ const page = await adminCtx.newPage();
295+ try {
296+ await page.goto(issueUrl);
297+ // Add a comment first
298+ await page.fill('textarea[name=body]', 'Comment to edit.');
299+ await page.click('form[action*="/comments"] button[type=submit]');
300+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
301+
302+ // Edit the comment
303+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
304+ await commentItem.locator('.inline-edit-details summary').click();
305+ await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
306+ await commentItem.locator('.inline-edit-form-area [type=submit]').click();
307+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
308+ expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
309+ } finally { await page.close(); }
310+ });
311+
312+ test('non-admin user can create an issue', async () => {
313+ const page = await aliceCtx.newPage();
314+ try {
315+ await page.goto(`${BASE}/my-repo/issues/new`);
316+ await page.fill('[name=title]', "Alice's issue");
317+ await page.click('form[action$="/issues"] button[type=submit]');
318+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
319+ expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
320+ } finally { await page.close(); }
321+ });
322+
323+ test('non-admin cannot comment on a closed issue', async () => {
324+ // Close the issue as admin first
325+ const issueNum = issueUrl.split('/issues/')[1];
326+ await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
327+
328+ const page = await aliceCtx.newPage();
329+ try {
330+ const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
331+ form: { body: 'comment on closed issue' },
332+ maxRedirects: 0,
333+ });
334+ // Non-admin gets redirected (silently ignored), not an error
335+ expect(resp.status()).toBe(302);
336+ // The comment should NOT appear
337+ await page.goto(issueUrl);
338+ const bodies = await page.locator('.timeline-body').allTextContents();
339+ expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
340+ } finally { await page.close(); }
341+ });
342+
343+ test('admin can delete issue', async () => {
344+ const issueNum = issueUrl.split('/issues/')[1];
345+ const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
346+ maxRedirects: 0,
347+ });
348+ expect(resp.status()).toBe(302);
349+ // Issue should be gone
350+ const page = await adminCtx.newPage();
351+ try {
352+ const checkResp = await page.request.get(issueUrl);
353+ expect(checkResp.status()).toBe(404);
354+ } finally { await page.close(); }
355+ });
356+});
357+
358+// ─── Repository description update ───────────────────────────────────────────
359+
360+describe('repo description', () => {
361+ let adminCtx: BrowserContext;
362+
363+ beforeAll(async () => { adminCtx = await loggedInContext(); });
364+ afterAll(async () => { await adminCtx.close(); });
365+
366+ test('updating repo description is reflected on list page', async () => {
367+ const page = await adminCtx.newPage();
368+ try {
369+ await page.goto(`${BASE}/my-repo/settings`);
370+ await page.fill('[name=description]', 'A freshly updated description');
371+ await page.click('form[action$="/settings"] button[type=submit]');
372+ expect(await page.locator('.form-success').isVisible()).toBe(true);
373+
374+ await page.goto(BASE);
375+ const desc = await page.locator('.repo-description').allTextContents();
376+ expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
377+ } finally { await page.close(); }
378+ });
379+});
380+
381+// ─── Issue and patch templates ────────────────────────────────────────────────
382+
383+describe('issue and patch templates', () => {
384+ let adminCtx: BrowserContext;
385+
386+ beforeAll(async () => { adminCtx = await loggedInContext(); });
387+ afterAll(async () => { await adminCtx.close(); });
388+
389+ test('issue template can be saved and is prefilled on new issue form', async () => {
390+ const page = await adminCtx.newPage();
391+ try {
392+ await page.goto(`${BASE}/my-repo/settings`);
393+ await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
394+ await page.click('form[action$="/settings"] button[type=submit]');
395+ expect(await page.locator('.form-success').isVisible()).toBe(true);
396+
397+ await page.goto(`${BASE}/my-repo/issues/new`);
398+ const body = await page.locator('[name=body]').inputValue();
399+ expect(body).toContain('## Steps to reproduce');
400+ expect(body).toContain('## Expected behavior');
401+ } finally { await page.close(); }
402+ });
403+
404+ test('patch template can be saved and is prefilled on new patch form', async () => {
405+ const page = await adminCtx.newPage();
406+ try {
407+ await page.goto(`${BASE}/my-repo/settings`);
408+ await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
409+ await page.click('form[action$="/settings"] button[type=submit]');
410+ expect(await page.locator('.form-success').isVisible()).toBe(true);
411+
412+ await page.goto(`${BASE}/my-repo/patches/new`);
413+ const desc = await page.locator('[name=description]').inputValue();
414+ expect(desc).toContain('## Summary');
415+ expect(desc).toContain('## Testing');
416+ } finally { await page.close(); }
417+ });
418+
419+ test('clearing the issue template removes prefill', async () => {
420+ const page = await adminCtx.newPage();
421+ try {
422+ await page.goto(`${BASE}/my-repo/settings`);
423+ await page.fill('[name=issue_template]', '');
424+ await page.click('form[action$="/settings"] button[type=submit]');
425+ expect(await page.locator('.form-success').isVisible()).toBe(true);
426+
427+ await page.goto(`${BASE}/my-repo/issues/new`);
428+ const body = await page.locator('[name=body]').inputValue();
429+ expect(body).toBe('');
430+ } finally { await page.close(); }
431+ });
432+
433+ test('clearing the patch template removes prefill', async () => {
434+ const page = await adminCtx.newPage();
435+ try {
436+ await page.goto(`${BASE}/my-repo/settings`);
437+ await page.fill('[name=patch_template]', '');
438+ await page.click('form[action$="/settings"] button[type=submit]');
439+ expect(await page.locator('.form-success').isVisible()).toBe(true);
440+
441+ await page.goto(`${BASE}/my-repo/patches/new`);
442+ const desc = await page.locator('[name=description]').inputValue();
443+ expect(desc).toBe('');
444+ } finally { await page.close(); }
445+ });
446+});
Atests/e2e.labels.test.ts
@@ -0,0 +1,614 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ writeTempFile,
12+} from './helpers.ts';
13+
14+let browser: Browser;
15+let server: Awaited<ReturnType<typeof spawnServer>>;
16+
17+beforeAll(async () => {
18+ await setupTestEnv();
19+ server = await spawnServer();
20+ browser = await chromium.launch();
21+
22+ // Register alice (needed for user label management)
23+ const regCtx = await browser.newContext();
24+ const regPage = await regCtx.newPage();
25+ try {
26+ await regPage.goto(`${BASE}/register`);
27+ await regPage.fill('[name=username]', 'alice');
28+ await regPage.fill('[name=password]', 'password123');
29+ await regPage.fill('[name=password2]', 'password123');
30+ await regPage.click('button[type=submit]');
31+ await regPage.waitForURL(BASE + '/');
32+ } finally { await regCtx.close(); }
33+});
34+
35+afterAll(async () => {
36+ await browser.close();
37+ await killServer(server);
38+});
39+
40+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
41+ const ctx = await browser.newContext();
42+ const page = await ctx.newPage();
43+ await login(page, username, password);
44+ await page.close();
45+ return ctx;
46+}
47+
48+// ─── Labels ───────────────────────────────────────────────────────────────────
49+
50+describe('labels', () => {
51+ let adminCtx: BrowserContext;
52+ let issueUrl: string;
53+ let patchUrl: string;
54+
55+ const VALID_PATCH = [
56+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
57+ 'From: Test User <test@example.com>',
58+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
59+ 'Subject: [PATCH] Add label-test.txt',
60+ '',
61+ '---',
62+ 'diff --git a/label-test.txt b/label-test.txt',
63+ 'new file mode 100644',
64+ 'index 0000000..9daeafb',
65+ '--- /dev/null',
66+ '+++ b/label-test.txt',
67+ '@@ -0,0 +1 @@',
68+ '+x',
69+ '',
70+ ].join('\n');
71+
72+ beforeAll(async () => {
73+ adminCtx = await loggedInContext();
74+
75+ // Create a dedicated repo for label tests
76+ const page = await adminCtx.newPage();
77+ try {
78+ await page.goto(`${BASE}/new`);
79+ await page.fill('[name=name]', 'label-repo');
80+ await page.click('form[action="/new"] button[type=submit]');
81+ await page.waitForURL(`${BASE}/label-repo`);
82+ } finally { await page.close(); }
83+
84+ // Create an issue
85+ const issuePage = await adminCtx.newPage();
86+ try {
87+ await issuePage.goto(`${BASE}/label-repo/issues/new`);
88+ await issuePage.fill('[name=title]', 'Labelled issue');
89+ await issuePage.click('form[action$="/issues"] button[type=submit]');
90+ await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
91+ issueUrl = issuePage.url();
92+ } finally { await issuePage.close(); }
93+
94+ // Create a patch
95+ writeTempFile('/tmp/label-test.patch', VALID_PATCH);
96+ const patchPage = await adminCtx.newPage();
97+ try {
98+ await patchPage.goto(`${BASE}/label-repo/patches/new`);
99+ await patchPage.fill('[name=title]', 'Labelled patch');
100+ await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
101+ await patchPage.click('form[action$="/patches"] button[type=submit]');
102+ await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
103+ patchUrl = patchPage.url();
104+ } finally { await patchPage.close(); }
105+ });
106+
107+ afterAll(async () => { await adminCtx.close(); });
108+
109+ test('create label in repo settings', async () => {
110+ const page = await adminCtx.newPage();
111+ try {
112+ await page.goto(`${BASE}/label-repo/settings`);
113+ await page.fill('input[name=name]', 'bug');
114+ await page.locator('input[type=color][name=color]').evaluate(
115+ (el: any) => { el.value = '#ff0000'; },
116+ );
117+ await page.click('form[action$="/settings/labels"] button[type=submit]');
118+ await page.waitForURL(/\/label-repo\/settings/);
119+ expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
120+ } finally { await page.close(); }
121+ });
122+
123+ test('create a second label', async () => {
124+ const page = await adminCtx.newPage();
125+ try {
126+ await page.goto(`${BASE}/label-repo/settings`);
127+ await page.fill('input[name=name]', 'enhancement');
128+ await page.locator('input[type=color][name=color]').evaluate(
129+ (el: any) => { el.value = '#00aa00'; },
130+ );
131+ await page.click('form[action$="/settings/labels"] button[type=submit]');
132+ await page.waitForURL(/\/label-repo\/settings/);
133+ const badges = await page.locator('.label-settings-name').allTextContents();
134+ expect(badges).toContain('bug');
135+ expect(badges).toContain('enhancement');
136+ } finally { await page.close(); }
137+ });
138+
139+ test('duplicate label name is rejected', async () => {
140+ const page = await adminCtx.newPage();
141+ try {
142+ await page.goto(`${BASE}/label-repo/settings`);
143+ await page.fill('input[name=name]', 'bug');
144+ await page.locator('input[type=color][name=color]').evaluate(
145+ (el: any) => { el.value = '#0000ff'; },
146+ );
147+ await page.click('form[action$="/settings/labels"] button[type=submit]');
148+ await page.waitForURL(/\/label-repo\/settings/);
149+ expect(await page.locator('.form-error').isVisible()).toBe(true);
150+ } finally { await page.close(); }
151+ });
152+
153+ test('non-admin cannot create labels', async () => {
154+ const ctx = await browser.newContext();
155+ try {
156+ const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
157+ form: { name: 'nope', color: '#123456' },
158+ maxRedirects: 0,
159+ });
160+ // Unauthenticated → redirected to /login
161+ expect(r.status()).toBe(302);
162+ expect(r.headers()['location']).toContain('/login');
163+ } finally { await ctx.close(); }
164+ });
165+
166+ test('assign label to issue', async () => {
167+ const page = await adminCtx.newPage();
168+ try {
169+ await page.goto(issueUrl);
170+ await page.selectOption('select[name=label_id]', { label: 'bug' });
171+ await page.click('form[action$="/labels/add"] button[type=submit]');
172+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
173+ expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
174+ } finally { await page.close(); }
175+ });
176+
177+ test('label appears on issue list', async () => {
178+ const page = await adminCtx.newPage();
179+ try {
180+ await page.goto(`${BASE}/label-repo/issues`);
181+ const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
182+ expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
183+ } finally { await page.close(); }
184+ });
185+
186+ test('filter issues by label shows only matching issues', async () => {
187+ // Create a second issue without the label
188+ const createPage = await adminCtx.newPage();
189+ try {
190+ await createPage.goto(`${BASE}/label-repo/issues/new`);
191+ await createPage.fill('[name=title]', 'Unlabelled issue');
192+ await createPage.click('form[action$="/issues"] button[type=submit]');
193+ await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
194+ } finally { await createPage.close(); }
195+
196+ // Open filter popup and apply label filter
197+ const page = await adminCtx.newPage();
198+ try {
199+ await page.goto(`${BASE}/label-repo/issues`);
200+ // Get the label id from the checkbox
201+ const checkbox = page.locator('.label-filter-item input[name=labels]').first();
202+ const labelId = await checkbox.getAttribute('value');
203+ expect(labelId).toBeTruthy();
204+
205+ // Navigate with the filter applied via URL
206+ await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
207+ const titles = await page.locator('.issue-title').allTextContents();
208+ expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
209+ expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
210+ } finally { await page.close(); }
211+ });
212+
213+ test('filter popup is visible without JS', async () => {
214+ // details/summary is a native HTML element — verify it renders
215+ const page = await adminCtx.newPage();
216+ try {
217+ await page.goto(`${BASE}/label-repo/issues`);
218+ expect(await page.locator('details.label-filter').isVisible()).toBe(true);
219+ expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
220+ } finally { await page.close(); }
221+ });
222+
223+ test('remove label from issue', async () => {
224+ const page = await adminCtx.newPage();
225+ try {
226+ await page.goto(issueUrl);
227+ await page.click('form[action$="/labels/remove"] button[type=submit]');
228+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
229+ // Label badge should no longer appear in the labels row
230+ const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
231+ expect(labelBadges).not.toContain('bug');
232+ } finally { await page.close(); }
233+ });
234+
235+ test('assign label to patch', async () => {
236+ const page = await adminCtx.newPage();
237+ try {
238+ await page.goto(patchUrl);
239+ await page.selectOption('select[name=label_id]', { label: 'enhancement' });
240+ await page.click('form[action$="/labels/add"] button[type=submit]');
241+ await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
242+ expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
243+ } finally { await page.close(); }
244+ });
245+
246+ test('label appears on patch list', async () => {
247+ const page = await adminCtx.newPage();
248+ try {
249+ await page.goto(`${BASE}/label-repo/patches`);
250+ const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
251+ expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
252+ } finally { await page.close(); }
253+ });
254+
255+ test('filter patches by label', async () => {
256+ const page = await adminCtx.newPage();
257+ try {
258+ await page.goto(`${BASE}/label-repo/patches`);
259+ // Find the checkbox for the 'enhancement' label specifically
260+ const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
261+ .locator('input[name=labels]');
262+ const labelId = await checkbox.getAttribute('value');
263+ expect(labelId).toBeTruthy();
264+
265+ await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
266+ const titles = await page.locator('.issue-title').allTextContents();
267+ expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
268+ } finally { await page.close(); }
269+ });
270+
271+ test('remove label from patch', async () => {
272+ const page = await adminCtx.newPage();
273+ try {
274+ await page.goto(patchUrl);
275+ await page.click('form[action$="/labels/remove"] button[type=submit]');
276+ await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
277+ const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
278+ expect(labelBadges).not.toContain('enhancement');
279+ } finally { await page.close(); }
280+ });
281+
282+ test('delete label removes it from settings list', async () => {
283+ const page = await adminCtx.newPage();
284+ try {
285+ await page.goto(`${BASE}/label-repo/settings`);
286+ const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
287+ await bugItem.locator('form[action$="/labels/delete"] button').click();
288+ await page.waitForURL(/\/label-repo\/settings/);
289+ const badges = await page.locator('.label-settings-name').allTextContents();
290+ expect(badges).not.toContain('bug');
291+ } finally { await page.close(); }
292+ });
293+
294+ test('deleted label no longer appears in filter popup', async () => {
295+ const page = await adminCtx.newPage();
296+ try {
297+ await page.goto(`${BASE}/label-repo/issues`);
298+ const filterLabels = await page.locator('.label-filter-item').allTextContents();
299+ expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
300+ } finally { await page.close(); }
301+ });
302+});
303+
304+// ─── User label management ────────────────────────────────────────────────────
305+
306+describe('user label management', () => {
307+ let adminCtx: BrowserContext;
308+ let aliceCtx: BrowserContext;
309+ // URL of an issue owned by alice
310+ let aliceIssueUrl: string;
311+ // URL of an issue owned by admin
312+ let adminIssueUrl: string;
313+ // label IDs, fetched from the filter inputs
314+ let bugLabelId: string;
315+
316+ const VALID_PATCH = [
317+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
318+ 'From: Test User <test@example.com>',
319+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
320+ 'Subject: [PATCH] Add ulm-test.txt',
321+ '',
322+ '---',
323+ 'diff --git a/ulm-test.txt b/ulm-test.txt',
324+ 'new file mode 100644',
325+ 'index 0000000..9daeafb',
326+ '--- /dev/null',
327+ '+++ b/ulm-test.txt',
328+ '@@ -0,0 +1 @@',
329+ '+x',
330+ '',
331+ ].join('\n');
332+
333+ beforeAll(async () => {
334+ adminCtx = await loggedInContext();
335+ aliceCtx = await loggedInContext('alice', 'password123');
336+
337+ // Create dedicated repo
338+ const repoPage = await adminCtx.newPage();
339+ try {
340+ await repoPage.goto(`${BASE}/new`);
341+ await repoPage.fill('[name=name]', 'ulm-repo');
342+ await repoPage.click('form[action="/new"] button[type=submit]');
343+ await repoPage.waitForURL(`${BASE}/ulm-repo`);
344+ } finally { await repoPage.close(); }
345+
346+ // Create labels 'bug' and 'feature'
347+ for (const name of ['bug', 'feature']) {
348+ const p = await adminCtx.newPage();
349+ try {
350+ await p.goto(`${BASE}/ulm-repo/settings`);
351+ await p.fill('input[name=name]', name);
352+ await p.click('form[action$="/settings/labels"] button[type=submit]');
353+ await p.waitForURL(/\/ulm-repo\/settings/);
354+ } finally { await p.close(); }
355+ }
356+
357+ // Create an issue owned by admin
358+ const adminIssuePage = await adminCtx.newPage();
359+ try {
360+ await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
361+ await adminIssuePage.fill('[name=title]', "Admin's issue");
362+ await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
363+ await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
364+ adminIssueUrl = adminIssuePage.url();
365+ } finally { await adminIssuePage.close(); }
366+
367+ // Create an issue owned by alice
368+ const aliceIssuePage = await aliceCtx.newPage();
369+ try {
370+ await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
371+ await aliceIssuePage.fill('[name=title]', "Alice's issue");
372+ await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
373+ await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
374+ aliceIssueUrl = aliceIssuePage.url();
375+ } finally { await aliceIssuePage.close(); }
376+
377+ // Grab the bug label id from the filter popup
378+ const filterPage = await adminCtx.newPage();
379+ try {
380+ await filterPage.goto(`${BASE}/ulm-repo/issues`);
381+ const checkbox = filterPage.locator('.label-filter-item')
382+ .filter({ hasText: 'bug' })
383+ .locator('input[name=labels]');
384+ bugLabelId = (await checkbox.getAttribute('value')) ?? '';
385+ } finally { await filterPage.close(); }
386+ });
387+
388+ afterAll(async () => {
389+ await adminCtx.close();
390+ await aliceCtx.close();
391+ });
392+
393+ // ── Settings ──
394+
395+ test('allow_user_labels checkbox is present in repo settings', async () => {
396+ const page = await adminCtx.newPage();
397+ try {
398+ await page.goto(`${BASE}/ulm-repo/settings`);
399+ expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
400+ } finally { await page.close(); }
401+ });
402+
403+ test('allow_user_labels is off by default', async () => {
404+ const page = await adminCtx.newPage();
405+ try {
406+ await page.goto(`${BASE}/ulm-repo/settings`);
407+ expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
408+ } finally { await page.close(); }
409+ });
410+
411+ // ── Label checkboxes hidden when setting is off ──
412+
413+ test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
414+ const page = await aliceCtx.newPage();
415+ try {
416+ await page.goto(`${BASE}/ulm-repo/issues/new`);
417+ expect(await page.locator('.label-checkbox-list').count()).toBe(0);
418+ } finally { await page.close(); }
419+ });
420+
421+ test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
422+ const page = await aliceCtx.newPage();
423+ try {
424+ await page.goto(`${BASE}/ulm-repo/patches/new`);
425+ expect(await page.locator('.label-checkbox-list').count()).toBe(0);
426+ } finally { await page.close(); }
427+ });
428+
429+ test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
430+ const page = await adminCtx.newPage();
431+ try {
432+ await page.goto(`${BASE}/ulm-repo/issues/new`);
433+ expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
434+ } finally { await page.close(); }
435+ });
436+
437+ // ── Enable the setting ──
438+
439+ test('admin can enable allow_user_labels', async () => {
440+ const page = await adminCtx.newPage();
441+ try {
442+ await page.goto(`${BASE}/ulm-repo/settings`);
443+ await page.check('input[name=allow_user_labels]');
444+ await page.click('form[action$="/settings"] button[type=submit]');
445+ await page.waitForURL(/\/ulm-repo\/settings/);
446+ // Verify it persisted
447+ await page.goto(`${BASE}/ulm-repo/settings`);
448+ expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
449+ } finally { await page.close(); }
450+ });
451+
452+ // ── Label checkboxes visible when setting is on ──
453+
454+ test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
455+ const page = await aliceCtx.newPage();
456+ try {
457+ await page.goto(`${BASE}/ulm-repo/issues/new`);
458+ expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
459+ const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
460+ expect(labels).toContain('bug');
461+ expect(labels).toContain('feature');
462+ } finally { await page.close(); }
463+ });
464+
465+ test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
466+ const page = await aliceCtx.newPage();
467+ try {
468+ await page.goto(`${BASE}/ulm-repo/patches/new`);
469+ expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
470+ } finally { await page.close(); }
471+ });
472+
473+ // ── Creating with labels selected ──
474+
475+ test('non-admin can create issue with label selected', async () => {
476+ const page = await aliceCtx.newPage();
477+ try {
478+ await page.goto(`${BASE}/ulm-repo/issues/new`);
479+ await page.fill('[name=title]', 'Issue with label');
480+ // Check the 'bug' label checkbox
481+ await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
482+ .locator('input[type=checkbox]').check();
483+ await page.click('form[action$="/issues"] button[type=submit]');
484+ await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
485+ const badges = await page.locator('.label-badge').allTextContents();
486+ expect(badges).toContain('bug');
487+ } finally { await page.close(); }
488+ });
489+
490+ test('non-admin can create patch with label selected', async () => {
491+ writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
492+ const page = await aliceCtx.newPage();
493+ try {
494+ await page.goto(`${BASE}/ulm-repo/patches/new`);
495+ await page.fill('[name=title]', 'Patch with label');
496+ await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
497+ await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
498+ .locator('input[type=checkbox]').check();
499+ await page.click('form[action$="/patches"] button[type=submit]');
500+ await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
501+ const badges = await page.locator('.label-badge').allTextContents();
502+ expect(badges).toContain('feature');
503+ } finally { await page.close(); }
504+ });
505+
506+ test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
507+ // Temporarily disable the setting, post with label_ids, re-enable
508+ await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
509+ form: { description: '', default_branch: 'main' }, // no allow_user_labels
510+ maxRedirects: 0,
511+ }).catch(() => {});
512+
513+ const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
514+ form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
515+ maxRedirects: 0,
516+ }).catch(() => null);
517+ // Should redirect to the new issue
518+ const location = resp?.headers()['location'] ?? '';
519+ const issueNum = location.split('/issues/')[1];
520+
521+ if (issueNum) {
522+ const page = await aliceCtx.newPage();
523+ try {
524+ await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
525+ const badges = await page.locator('.label-badge').allTextContents();
526+ expect(badges).not.toContain('bug');
527+ } finally { await page.close(); }
528+ }
529+
530+ // Re-enable for subsequent tests
531+ await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
532+ form: { description: '', default_branch: 'main', allow_user_labels: '1' },
533+ maxRedirects: 0,
534+ }).catch(() => {});
535+ });
536+
537+ // ── Add/remove labels on existing items ──
538+
539+ test('non-admin can add label to their own issue', async () => {
540+ const issueNum = aliceIssueUrl.split('/issues/')[1];
541+ const page = await aliceCtx.newPage();
542+ try {
543+ await page.goto(aliceIssueUrl);
544+ await page.selectOption('select[name=label_id]', { label: 'bug' });
545+ await page.click('form[action$="/labels/add"] button[type=submit]');
546+ await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
547+ expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
548+ } finally { await page.close(); }
549+ });
550+
551+ test('non-admin can remove label from their own issue', async () => {
552+ const issueNum = aliceIssueUrl.split('/issues/')[1];
553+ const page = await aliceCtx.newPage();
554+ try {
555+ await page.goto(aliceIssueUrl);
556+ await page.click('form[action$="/labels/remove"] button[type=submit]');
557+ await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
558+ const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
559+ expect(badges).not.toContain('bug');
560+ } finally { await page.close(); }
561+ });
562+
563+ test('non-admin cannot add label to another user\'s issue', async () => {
564+ const issueNum = adminIssueUrl.split('/issues/')[1];
565+ const resp = await aliceCtx.request.post(
566+ `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
567+ { form: { label_id: bugLabelId }, maxRedirects: 0 },
568+ );
569+ expect(resp.status()).toBe(403);
570+ });
571+
572+ test('unauthenticated user gets 401 adding a label', async () => {
573+ const issueNum = aliceIssueUrl.split('/issues/')[1];
574+ const ctx = await browser.newContext();
575+ try {
576+ const resp = await ctx.request.post(
577+ `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
578+ { form: { label_id: bugLabelId }, maxRedirects: 0 },
579+ );
580+ expect(resp.status()).toBe(401);
581+ } finally { await ctx.close(); }
582+ });
583+
584+ // ── Disable setting and verify enforcement ──
585+
586+ test('admin can disable allow_user_labels', async () => {
587+ const page = await adminCtx.newPage();
588+ try {
589+ await page.goto(`${BASE}/ulm-repo/settings`);
590+ await page.uncheck('input[name=allow_user_labels]');
591+ await page.click('form[action$="/settings"] button[type=submit]');
592+ await page.waitForURL(/\/ulm-repo\/settings/);
593+ await page.goto(`${BASE}/ulm-repo/settings`);
594+ expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
595+ } finally { await page.close(); }
596+ });
597+
598+ test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
599+ const issueNum = aliceIssueUrl.split('/issues/')[1];
600+ const resp = await aliceCtx.request.post(
601+ `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
602+ { form: { label_id: bugLabelId }, maxRedirects: 0 },
603+ );
604+ expect(resp.status()).toBe(403);
605+ });
606+
607+ test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
608+ const page = await aliceCtx.newPage();
609+ try {
610+ await page.goto(`${BASE}/ulm-repo/issues/new`);
611+ expect(await page.locator('.label-checkbox-list').count()).toBe(0);
612+ } finally { await page.close(); }
613+ });
614+});
Atests/e2e.navigation.test.ts
@@ -0,0 +1,379 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ seedRepo,
12+ seedBranch,
13+ seedSubdir,
14+} from './helpers.ts';
15+
16+let browser: Browser;
17+let server: Awaited<ReturnType<typeof spawnServer>>;
18+
19+beforeAll(async () => {
20+ await setupTestEnv();
21+ server = await spawnServer();
22+ browser = await chromium.launch();
23+
24+ // Create my-repo and seed it
25+ const adminCtx = await browser.newContext();
26+ const adminPage = await adminCtx.newPage();
27+ try {
28+ await login(adminPage);
29+ await adminPage.goto(`${BASE}/new`);
30+ await adminPage.fill('[name=name]', 'my-repo');
31+ await adminPage.click('form[action="/new"] button[type=submit]');
32+ await adminPage.waitForURL(`${BASE}/my-repo`);
33+ } finally { await adminCtx.close(); }
34+
35+ await seedRepo('my-repo');
36+});
37+
38+afterAll(async () => {
39+ await browser.close();
40+ await killServer(server);
41+});
42+
43+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
44+ const ctx = await browser.newContext();
45+ const page = await ctx.newPage();
46+ await login(page, username, password);
47+ await page.close();
48+ return ctx;
49+}
50+
51+// ─── Branch selector ──────────────────────────────────────────────────────────
52+
53+describe('branch selector', () => {
54+ let adminCtx: BrowserContext;
55+
56+ beforeAll(async () => {
57+ adminCtx = await loggedInContext();
58+ // Add a second branch so the selector is meaningful
59+ await seedBranch('my-repo', 'dev');
60+ });
61+
62+ afterAll(async () => { await adminCtx.close(); });
63+
64+ test('branch selector appears on repo home', async () => {
65+ const page = await adminCtx.newPage();
66+ try {
67+ await page.goto(`${BASE}/my-repo`);
68+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
69+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
70+ } finally { await page.close(); }
71+ });
72+
73+ test('branch selector shows all branches on repo home', async () => {
74+ const page = await adminCtx.newPage();
75+ try {
76+ await page.goto(`${BASE}/my-repo`);
77+ const options = await page.locator('.branch-select option').allTextContents();
78+ expect(options).toContain('main');
79+ expect(options).toContain('dev');
80+ } finally { await page.close(); }
81+ });
82+
83+ test('branch selector appears on file tree with current ref selected', async () => {
84+ const page = await adminCtx.newPage();
85+ try {
86+ await page.goto(`${BASE}/my-repo/tree/main`);
87+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
88+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
89+ } finally { await page.close(); }
90+ });
91+
92+ test('branch selector appears on commit log with current ref selected', async () => {
93+ const page = await adminCtx.newPage();
94+ try {
95+ await page.goto(`${BASE}/my-repo/commits/main`);
96+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
97+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
98+ } finally { await page.close(); }
99+ });
100+
101+ test('branch selector appears on file blob', async () => {
102+ const page = await adminCtx.newPage();
103+ try {
104+ await page.goto(`${BASE}/my-repo/blob/main/README.md`);
105+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
106+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
107+ } finally { await page.close(); }
108+ });
109+
110+ test('switching branch on commit log navigates to the selected branch', async () => {
111+ const page = await adminCtx.newPage();
112+ try {
113+ await page.goto(`${BASE}/my-repo/commits/main`);
114+ await page.locator('.branch-select').selectOption('dev');
115+ await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
116+ await page.waitForURL(`${BASE}/my-repo/commits/dev`);
117+ expect(page.url()).toContain('/commits/dev');
118+ } finally { await page.close(); }
119+ });
120+
121+ test('switching branch on file tree navigates to the selected branch', async () => {
122+ const page = await adminCtx.newPage();
123+ try {
124+ await page.goto(`${BASE}/my-repo/tree/main`);
125+ await page.locator('.branch-select').selectOption('dev');
126+ await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
127+ await page.waitForURL(`${BASE}/my-repo/tree/dev`);
128+ expect(page.url()).toContain('/tree/dev');
129+ } finally { await page.close(); }
130+ });
131+
132+ test('branch-switch route preserves subpath when switching tree', async () => {
133+ const page = await adminCtx.newPage();
134+ try {
135+ const resp = await page.request.get(
136+ `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`,
137+ { maxRedirects: 0 },
138+ ).catch(r => r);
139+ // 302 redirect to /my-repo/tree/dev/src/foo
140+ const loc = (resp as any).headers()?.['location'] ?? '';
141+ expect(loc).toContain('/tree/dev/src/foo');
142+ } finally { await page.close(); }
143+ });
144+
145+ test('branch-switch route redirects commits view correctly', async () => {
146+ const page = await adminCtx.newPage();
147+ try {
148+ const resp = await page.request.get(
149+ `${BASE}/my-repo/branch-switch?view=commits&rev=dev`,
150+ { maxRedirects: 0 },
151+ ).catch(r => r);
152+ const loc = (resp as any).headers()?.['location'] ?? '';
153+ expect(loc).toContain('/commits/dev');
154+ } finally { await page.close(); }
155+ });
156+
157+ test('branch-switch route redirects blob view correctly', async () => {
158+ const page = await adminCtx.newPage();
159+ try {
160+ const resp = await page.request.get(
161+ `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`,
162+ { maxRedirects: 0 },
163+ ).catch(r => r);
164+ const loc = (resp as any).headers()?.['location'] ?? '';
165+ expect(loc).toContain('/blob/dev/README.md');
166+ } finally { await page.close(); }
167+ });
168+});
169+
170+// ─── Default branch settings ──────────────────────────────────────────────────
171+
172+describe('default branch settings', () => {
173+ // Runs after 'branch selector', so my-repo already has both main and dev branches.
174+ let adminCtx: BrowserContext;
175+
176+ beforeAll(async () => { adminCtx = await loggedInContext(); });
177+ afterAll(async () => { await adminCtx.close(); });
178+
179+ test('settings page shows default branch select', async () => {
180+ const page = await adminCtx.newPage();
181+ try {
182+ await page.goto(`${BASE}/my-repo/settings`);
183+ expect(await page.locator('#default_branch').isVisible()).toBe(true);
184+ const options = await page.locator('#default_branch option').allTextContents();
185+ expect(options).toContain('main');
186+ expect(options).toContain('dev');
187+ } finally { await page.close(); }
188+ });
189+
190+ test('current default branch is pre-selected', async () => {
191+ const page = await adminCtx.newPage();
192+ try {
193+ await page.goto(`${BASE}/my-repo/settings`);
194+ expect(await page.locator('#default_branch').inputValue()).toBe('main');
195+ } finally { await page.close(); }
196+ });
197+
198+ test('changing default branch saves and is reflected in the repo home', async () => {
199+ const page = await adminCtx.newPage();
200+ try {
201+ await page.goto(`${BASE}/my-repo/settings`);
202+ await page.locator('#default_branch').selectOption('dev');
203+ await page.click('form[action$="/settings"] button[type=submit]');
204+ expect(await page.locator('.form-success').isVisible()).toBe(true);
205+ // The select now shows dev as current
206+ expect(await page.locator('#default_branch').inputValue()).toBe('dev');
207+
208+ // Repo home branch selector should reflect the new default
209+ await page.goto(`${BASE}/my-repo`);
210+ expect(await page.locator('.branch-select').inputValue()).toBe('dev');
211+ } finally { await page.close(); }
212+ });
213+
214+ test('commit log link in repo nav uses the new default branch', async () => {
215+ const page = await adminCtx.newPage();
216+ try {
217+ await page.goto(`${BASE}/my-repo`);
218+ const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
219+ expect(commitsHref).toContain('/commits/dev');
220+ } finally { await page.close(); }
221+ });
222+
223+ test('changing default branch back to main restores original state', async () => {
224+ const page = await adminCtx.newPage();
225+ try {
226+ await page.goto(`${BASE}/my-repo/settings`);
227+ await page.locator('#default_branch').selectOption('main');
228+ await page.click('form[action$="/settings"] button[type=submit]');
229+ expect(await page.locator('.form-success').isVisible()).toBe(true);
230+ expect(await page.locator('#default_branch').inputValue()).toBe('main');
231+ } finally { await page.close(); }
232+ });
233+
234+ test('settings page shows hint instead of select when repo has no branches', async () => {
235+ // Create an empty repo (no commits → no branches)
236+ const page = await adminCtx.newPage();
237+ try {
238+ await page.goto(`${BASE}/new`);
239+ await page.fill('[name=name]', 'empty-for-branch-test');
240+ await page.click('form[action="/new"] button[type=submit]');
241+ await page.waitForURL(`${BASE}/empty-for-branch-test`);
242+
243+ await page.goto(`${BASE}/empty-for-branch-test/settings`);
244+ expect(await page.locator('#default_branch').count()).toBe(0);
245+ expect(await page.locator('.form-hint').isVisible()).toBe(true);
246+ } finally { await page.close(); }
247+ });
248+});
249+
250+// ─── File browser ─────────────────────────────────────────────────────────────
251+
252+describe('file browser', () => {
253+ // my-repo already has README.md + index.js from the file-level beforeAll.
254+ // We add a subdirectory here so we can test directory navigation.
255+ let adminCtx: BrowserContext;
256+
257+ beforeAll(async () => {
258+ adminCtx = await loggedInContext();
259+ await seedSubdir('my-repo', 'src', {
260+ 'app.ts': 'export {};\n',
261+ 'README.md': '# src readme\n',
262+ });
263+ });
264+
265+ afterAll(async () => { await adminCtx.close(); });
266+
267+ test('repo home shows file tree instead of recent commits', async () => {
268+ const page = await adminCtx.newPage();
269+ try {
270+ await page.goto(`${BASE}/my-repo`);
271+ expect(await page.locator('.file-tree').isVisible()).toBe(true);
272+ expect(await page.locator('.repo-commits-section').count()).toBe(0);
273+ } finally { await page.close(); }
274+ });
275+
276+ test('repo home file tree lists files and directories', async () => {
277+ const page = await adminCtx.newPage();
278+ try {
279+ await page.goto(`${BASE}/my-repo`);
280+ const names = await page.locator('.file-name a').allTextContents();
281+ expect(names).toContain('README.md');
282+ expect(names).toContain('index.js');
283+ expect(names).toContain('src');
284+ } finally { await page.close(); }
285+ });
286+
287+ test('directories appear before files in file tree', async () => {
288+ const page = await adminCtx.newPage();
289+ try {
290+ await page.goto(`${BASE}/my-repo`);
291+ const names = await page.locator('.file-name a').allTextContents();
292+ const srcIdx = names.indexOf('src');
293+ const readmeIdx = names.indexOf('README.md');
294+ expect(srcIdx).toBeGreaterThanOrEqual(0);
295+ expect(readmeIdx).toBeGreaterThanOrEqual(0);
296+ expect(srcIdx).toBeLessThan(readmeIdx);
297+ } finally { await page.close(); }
298+ });
299+
300+ test('no ".." entry at repository root', async () => {
301+ const page = await adminCtx.newPage();
302+ try {
303+ await page.goto(`${BASE}/my-repo`);
304+ const names = await page.locator('.file-name a').allTextContents();
305+ expect(names).not.toContain('..');
306+ } finally { await page.close(); }
307+ });
308+
309+ test('clicking directory navigates into it', async () => {
310+ const page = await adminCtx.newPage();
311+ try {
312+ await page.goto(`${BASE}/my-repo`);
313+ await page.locator('.file-name a', { hasText: 'src' }).click();
314+ await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
315+ expect(page.url()).toContain('/tree/main/src');
316+ } finally { await page.close(); }
317+ });
318+
319+ test('".." entry appears in subdirectory', async () => {
320+ const page = await adminCtx.newPage();
321+ try {
322+ await page.goto(`${BASE}/my-repo/tree/main/src`);
323+ const names = await page.locator('.file-name a').allTextContents();
324+ expect(names).toContain('..');
325+ } finally { await page.close(); }
326+ });
327+
328+ test('".." at one level deep links to tree root', async () => {
329+ const page = await adminCtx.newPage();
330+ try {
331+ await page.goto(`${BASE}/my-repo/tree/main/src`);
332+ const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
333+ expect(upHref).toBe('/my-repo/tree/main');
334+ } finally { await page.close(); }
335+ });
336+
337+ test('files in subdirectory show plain names, not full paths', async () => {
338+ const page = await adminCtx.newPage();
339+ try {
340+ await page.goto(`${BASE}/my-repo/tree/main/src`);
341+ const names = await page.locator('.file-name a').allTextContents();
342+ expect(names).toContain('app.ts');
343+ // Must NOT contain the full path with prefix
344+ expect(names).not.toContain('src/app.ts');
345+ expect(names).not.toContain('src/README.md');
346+ } finally { await page.close(); }
347+ });
348+
349+ test('readme is shown below file tree on repo home', async () => {
350+ const page = await adminCtx.newPage();
351+ try {
352+ await page.goto(`${BASE}/my-repo`);
353+ const treeBox = await page.locator('.file-tree').boundingBox();
354+ const readmeBox = await page.locator('.readme-section').boundingBox();
355+ expect(treeBox).not.toBeNull();
356+ expect(readmeBox).not.toBeNull();
357+ expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
358+ } finally { await page.close(); }
359+ });
360+
361+ test('readme in subdirectory is shown when present', async () => {
362+ const page = await adminCtx.newPage();
363+ try {
364+ await page.goto(`${BASE}/my-repo/tree/main/src`);
365+ expect(await page.locator('.readme-section').isVisible()).toBe(true);
366+ expect(await page.locator('.readme-section .markdown-body').innerHTML())
367+ .toContain('src readme');
368+ } finally { await page.close(); }
369+ });
370+
371+ test('file tree on /tree/:ref also shows readme', async () => {
372+ const page = await adminCtx.newPage();
373+ try {
374+ await page.goto(`${BASE}/my-repo/tree/main`);
375+ expect(await page.locator('.file-tree').isVisible()).toBe(true);
376+ expect(await page.locator('.readme-section').isVisible()).toBe(true);
377+ } finally { await page.close(); }
378+ });
379+});
Atests/e2e.pagination.test.ts
@@ -0,0 +1,200 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+} from './helpers.ts';
12+
13+let browser: Browser;
14+let server: Awaited<ReturnType<typeof spawnServer>>;
15+
16+beforeAll(async () => {
17+ await setupTestEnv();
18+ server = await spawnServer();
19+ browser = await chromium.launch();
20+});
21+
22+afterAll(async () => {
23+ await browser.close();
24+ await killServer(server);
25+});
26+
27+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
28+ const ctx = await browser.newContext();
29+ const page = await ctx.newPage();
30+ await login(page, username, password);
31+ await page.close();
32+ return ctx;
33+}
34+
35+// Helper: create N issues in a repo using the server API (no browser rendering)
36+async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
37+ for (let i = 1; i <= count; i++) {
38+ await ctx.request.fetch(`${BASE}/${repo}/issues`, {
39+ method: 'POST',
40+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41+ data: `title=Issue+number+${i}&body=`,
42+ maxRedirects: 0,
43+ }).catch(() => {}); // 302 redirect throws; that's fine
44+ }
45+}
46+
47+// Helper: create N patches in a repo using the server API
48+async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
49+ const VALID_PATCH = [
50+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
51+ 'From: Test User <test@example.com>',
52+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
53+ 'Subject: [PATCH] Add f.txt',
54+ '',
55+ '---',
56+ 'diff --git a/f.txt b/f.txt',
57+ 'new file mode 100644',
58+ 'index 0000000..9daeafb',
59+ '--- /dev/null',
60+ '+++ b/f.txt',
61+ '@@ -0,0 +1 @@',
62+ '+x',
63+ '',
64+ ].join('\n');
65+
66+ for (let i = 1; i <= count; i++) {
67+ await ctx.request.fetch(`${BASE}/${repo}/patches`, {
68+ method: 'POST',
69+ multipart: {
70+ title: `Patch number ${i}`,
71+ patch_file: { name: 'bulk.patch', mimeType: 'text/plain', buffer: Buffer.from(VALID_PATCH) },
72+ },
73+ maxRedirects: 0,
74+ }).catch(() => {});
75+ }
76+}
77+
78+// ─── Pagination ───────────────────────────────────────────────────────────────
79+
80+describe('pagination', () => {
81+ let adminCtx: BrowserContext;
82+
83+ beforeAll(async () => {
84+ adminCtx = await loggedInContext();
85+
86+ // Create a repo dedicated to pagination testing
87+ const page = await adminCtx.newPage();
88+ try {
89+ await page.goto(`${BASE}/new`);
90+ await page.fill('[name=name]', 'paged-repo');
91+ await page.click('form[action="/new"] button[type=submit]');
92+ await page.waitForURL(`${BASE}/paged-repo`);
93+ } finally { await page.close(); }
94+
95+ // Create 21 issues via the API (triggers page 2 at 20 per page)
96+ await bulkCreateIssues(adminCtx, 'paged-repo', 21);
97+
98+ // Create 21 patches via browser (patch upload requires multipart)
99+ await bulkCreatePatches(adminCtx, 'paged-repo', 21);
100+ });
101+
102+ afterAll(async () => { await adminCtx.close(); });
103+
104+ // ── Repo list pagination ──────────────────────────────────────────────────
105+
106+ test('repo list page 1 shows repos and no pagination when few repos', async () => {
107+ // With only a handful of test repos (< 20), there should be no pagination nav
108+ const page = await adminCtx.newPage();
109+ try {
110+ await page.goto(BASE);
111+ // Repos are shown
112+ expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
113+ } finally { await page.close(); }
114+ });
115+
116+ // ── Issue pagination ──────────────────────────────────────────────────────
117+
118+ test('issue list page 1 shows at most 20 items', async () => {
119+ const page = await adminCtx.newPage();
120+ try {
121+ await page.goto(`${BASE}/paged-repo/issues`);
122+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
123+ } finally { await page.close(); }
124+ });
125+
126+ test('issue list pagination nav appears when more than 20 issues', async () => {
127+ const page = await adminCtx.newPage();
128+ try {
129+ await page.goto(`${BASE}/paged-repo/issues`);
130+ expect(await page.locator('.pagination').isVisible()).toBe(true);
131+ } finally { await page.close(); }
132+ });
133+
134+ test('issue list page 2 shows remaining issues', async () => {
135+ const page = await adminCtx.newPage();
136+ try {
137+ await page.goto(`${BASE}/paged-repo/issues?page=2`);
138+ const count = await page.locator('.issue-item').count();
139+ expect(count).toBeGreaterThan(0);
140+ expect(count).toBeLessThanOrEqual(20);
141+ } finally { await page.close(); }
142+ });
143+
144+ test('issue list page 2 prev link goes to page 1', async () => {
145+ const page = await adminCtx.newPage();
146+ try {
147+ await page.goto(`${BASE}/paged-repo/issues?page=2`);
148+ const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
149+ expect(prevHref).toContain('page=1');
150+ } finally { await page.close(); }
151+ });
152+
153+ test('issue list page 1 next link goes to page 2', async () => {
154+ const page = await adminCtx.newPage();
155+ try {
156+ await page.goto(`${BASE}/paged-repo/issues`);
157+ const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
158+ expect(nextHref).toContain('page=2');
159+ } finally { await page.close(); }
160+ });
161+
162+ // ── Patch pagination ──────────────────────────────────────────────────────
163+
164+ test('patch list page 1 shows at most 20 items', async () => {
165+ const page = await adminCtx.newPage();
166+ try {
167+ await page.goto(`${BASE}/paged-repo/patches`);
168+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
169+ } finally { await page.close(); }
170+ });
171+
172+ test('patch list pagination nav appears when more than 20 patches', async () => {
173+ const page = await adminCtx.newPage();
174+ try {
175+ await page.goto(`${BASE}/paged-repo/patches`);
176+ expect(await page.locator('.pagination').isVisible()).toBe(true);
177+ } finally { await page.close(); }
178+ });
179+
180+ test('patch list page 2 shows remaining patches', async () => {
181+ const page = await adminCtx.newPage();
182+ try {
183+ await page.goto(`${BASE}/paged-repo/patches?page=2`);
184+ const count = await page.locator('.issue-item').count();
185+ expect(count).toBeGreaterThan(0);
186+ } finally { await page.close(); }
187+ });
188+
189+ // ── Commit log pagination ─────────────────────────────────────────────────
190+
191+ test('commit log with few commits shows no cursor nav', async () => {
192+ // paged-repo has no commits (empty repo) — there's no commit log to paginate
193+ // We verify the page loads without pagination controls
194+ const page = await adminCtx.newPage();
195+ try {
196+ await page.goto(`${BASE}/paged-repo`);
197+ expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
198+ } finally { await page.close(); }
199+ });
200+});
Atests/e2e.patches.test.ts
@@ -0,0 +1,718 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import { existsSync, readFileSync } from 'node:fs';
5+import {
6+ BASE,
7+ DATA_DIR,
8+ ADMIN_PASS,
9+ setupTestEnv,
10+ spawnServer,
11+ killServer,
12+ login,
13+ seedRepo,
14+ writeTempFile,
15+ gitOutput,
16+} from './helpers.ts';
17+
18+let browser: Browser;
19+let server: Awaited<ReturnType<typeof spawnServer>>;
20+
21+beforeAll(async () => {
22+ await setupTestEnv();
23+ server = await spawnServer();
24+ browser = await chromium.launch();
25+
26+ // Register alice
27+ const regCtx = await browser.newContext();
28+ const regPage = await regCtx.newPage();
29+ try {
30+ await regPage.goto(`${BASE}/register`);
31+ await regPage.fill('[name=username]', 'alice');
32+ await regPage.fill('[name=password]', 'password123');
33+ await regPage.fill('[name=password2]', 'password123');
34+ await regPage.click('button[type=submit]');
35+ await regPage.waitForURL(BASE + '/');
36+ } finally { await regCtx.close(); }
37+
38+ // Create my-repo
39+ const adminCtx = await browser.newContext();
40+ const adminPage = await adminCtx.newPage();
41+ try {
42+ await login(adminPage);
43+ await adminPage.goto(`${BASE}/new`);
44+ await adminPage.fill('[name=name]', 'my-repo');
45+ await adminPage.click('form[action="/new"] button[type=submit]');
46+ await adminPage.waitForURL(`${BASE}/my-repo`);
47+ } finally { await adminCtx.close(); }
48+
49+ await seedRepo('my-repo');
50+});
51+
52+afterAll(async () => {
53+ await browser.close();
54+ await killServer(server);
55+});
56+
57+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
58+ const ctx = await browser.newContext();
59+ const page = await ctx.newPage();
60+ await login(page, username, password);
61+ await page.close();
62+ return ctx;
63+}
64+
65+// ─── Patches ──────────────────────────────────────────────────────────────────
66+
67+describe('patches', () => {
68+ let adminCtx: BrowserContext;
69+ let cleanPatchUrl: string;
70+ let conflictPatchUrl: string;
71+ let closePatchUrl: string;
72+
73+ // Adds a new file — applies cleanly to my-repo
74+ const CLEAN_PATCH = [
75+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
76+ 'From: Test User <test@example.com>',
77+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
78+ 'Subject: [PATCH] Add patch-test.txt',
79+ '',
80+ '---',
81+ 'diff --git a/patch-test.txt b/patch-test.txt',
82+ 'new file mode 100644',
83+ 'index 0000000..9daeafb',
84+ '--- /dev/null',
85+ '+++ b/patch-test.txt',
86+ '@@ -0,0 +1 @@',
87+ '+patch test content',
88+ '',
89+ ].join('\n');
90+
91+ // References non-existent lines in README.md — always conflicts
92+ const CONFLICT_PATCH = [
93+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
94+ 'From: Test User <test@example.com>',
95+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
96+ 'Subject: [PATCH] Modify README',
97+ '',
98+ '---',
99+ 'diff --git a/README.md b/README.md',
100+ 'index abc1234..def5678 100644',
101+ '--- a/README.md',
102+ '+++ b/README.md',
103+ '@@ -50,3 +50,3 @@',
104+ ' nonexistent context line',
105+ '-nonexistent old line',
106+ '+nonexistent new line',
107+ '',
108+ ].join('\n');
109+
110+ // Adds another new file — for testing close flow
111+ const CLOSE_PATCH = [
112+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
113+ 'From: Test User <test@example.com>',
114+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
115+ 'Subject: [PATCH] Add patch-close.txt',
116+ '',
117+ '---',
118+ 'diff --git a/patch-close.txt b/patch-close.txt',
119+ 'new file mode 100644',
120+ 'index 0000000..9daeafb',
121+ '--- /dev/null',
122+ '+++ b/patch-close.txt',
123+ '@@ -0,0 +1 @@',
124+ '+close test',
125+ '',
126+ ].join('\n');
127+
128+ beforeAll(async () => {
129+ adminCtx = await loggedInContext();
130+ });
131+
132+ afterAll(async () => { await adminCtx.close(); });
133+
134+ test('reject file without patch markers', async () => {
135+ writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
136+ const page = await adminCtx.newPage();
137+ try {
138+ await page.goto(`${BASE}/my-repo/patches/new`);
139+ await page.fill('[name=title]', 'Bad patch');
140+ await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
141+ await page.click('form[action$="/patches"] button[type=submit]');
142+ expect(await page.locator('.form-error').textContent()).toContain('valid patch');
143+ } finally { await page.close(); }
144+ });
145+
146+ test('reject patch missing Subject header', async () => {
147+ writeTempFile('/tmp/no-subject.patch', [
148+ 'From: Test User <test@example.com>',
149+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
150+ '',
151+ '---',
152+ 'diff --git a/f.txt b/f.txt',
153+ 'new file mode 100644',
154+ '--- /dev/null',
155+ '+++ b/f.txt',
156+ '@@ -0,0 +1 @@',
157+ '+x',
158+ '',
159+ ].join('\n'));
160+ const page = await adminCtx.newPage();
161+ try {
162+ await page.goto(`${BASE}/my-repo/patches/new`);
163+ await page.fill('[name=title]', 'No subject');
164+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
165+ await page.click('form[action$="/patches"] button[type=submit]');
166+ expect(await page.locator('.form-error').textContent()).toContain('Subject');
167+ } finally { await page.close(); }
168+ });
169+
170+ test('reject patch missing From header', async () => {
171+ writeTempFile('/tmp/no-from.patch', [
172+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
173+ 'Subject: [PATCH] Add f.txt',
174+ '',
175+ '---',
176+ 'diff --git a/f.txt b/f.txt',
177+ 'new file mode 100644',
178+ '--- /dev/null',
179+ '+++ b/f.txt',
180+ '@@ -0,0 +1 @@',
181+ '+x',
182+ '',
183+ ].join('\n'));
184+ const page = await adminCtx.newPage();
185+ try {
186+ await page.goto(`${BASE}/my-repo/patches/new`);
187+ await page.fill('[name=title]', 'No from');
188+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
189+ await page.click('form[action$="/patches"] button[type=submit]');
190+ expect(await page.locator('.form-error').textContent()).toContain('From');
191+ } finally { await page.close(); }
192+ });
193+
194+ test('reject patch missing Date header', async () => {
195+ writeTempFile('/tmp/no-date.patch', [
196+ 'From: Test User <test@example.com>',
197+ 'Subject: [PATCH] Add f.txt',
198+ '',
199+ '---',
200+ 'diff --git a/f.txt b/f.txt',
201+ 'new file mode 100644',
202+ '--- /dev/null',
203+ '+++ b/f.txt',
204+ '@@ -0,0 +1 @@',
205+ '+x',
206+ '',
207+ ].join('\n'));
208+ const page = await adminCtx.newPage();
209+ try {
210+ await page.goto(`${BASE}/my-repo/patches/new`);
211+ await page.fill('[name=title]', 'No date');
212+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
213+ await page.click('form[action$="/patches"] button[type=submit]');
214+ expect(await page.locator('.form-error').textContent()).toContain('Date');
215+ } finally { await page.close(); }
216+ });
217+
218+ test('upload clean patch', async () => {
219+ writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
220+ const page = await adminCtx.newPage();
221+ try {
222+ await page.goto(`${BASE}/my-repo/patches/new`);
223+ await page.fill('[name=title]', 'Add patch-test.txt');
224+ await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
225+ await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
226+ await page.click('form[action$="/patches"] button[type=submit]');
227+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
228+ cleanPatchUrl = page.url();
229+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
230+ } finally { await page.close(); }
231+ });
232+
233+ test('changes tab shows commit metadata card', async () => {
234+ const page = await adminCtx.newPage();
235+ try {
236+ await page.goto(cleanPatchUrl + '?tab=changes');
237+ expect(await page.locator('.commit-card').isVisible()).toBe(true);
238+ expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
239+ expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
240+ expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
241+ expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
242+ } finally { await page.close(); }
243+ });
244+
245+ test('patch description renders markdown', async () => {
246+ const page = await adminCtx.newPage();
247+ try {
248+ await page.goto(cleanPatchUrl);
249+ expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
250+ } finally { await page.close(); }
251+ });
252+
253+ test('clean patch shows apply-clean status immediately', async () => {
254+ const page = await adminCtx.newPage();
255+ try {
256+ await page.goto(cleanPatchUrl);
257+ expect(await page.locator('.apply-result').isVisible()).toBe(true);
258+ expect(await page.locator('.apply-clean').isVisible()).toBe(true);
259+ } finally { await page.close(); }
260+ });
261+
262+ test('merge button appears for clean patch', async () => {
263+ const page = await adminCtx.newPage();
264+ try {
265+ await page.goto(cleanPatchUrl);
266+ expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
267+ } finally { await page.close(); }
268+ });
269+
270+ test('patch diff is displayed with highlighted table', async () => {
271+ const page = await adminCtx.newPage();
272+ try {
273+ await page.goto(cleanPatchUrl + '?tab=changes');
274+ expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
275+ expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
276+ } finally { await page.close(); }
277+ });
278+
279+ test('patch appears in open list', async () => {
280+ const page = await adminCtx.newPage();
281+ try {
282+ await page.goto(`${BASE}/my-repo/patches`);
283+ const titles = await page.locator('.issue-title').allTextContents();
284+ expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
285+ } finally { await page.close(); }
286+ });
287+
288+ test('unauthenticated user is redirected to login from patch upload', async () => {
289+ const ctx = await browser.newContext();
290+ const page = await ctx.newPage();
291+ try {
292+ await page.goto(`${BASE}/my-repo/patches/new`);
293+ expect(page.url()).toContain('/login');
294+ } finally { await ctx.close(); }
295+ });
296+
297+ test('upload conflict patch', async () => {
298+ writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
299+ const page = await adminCtx.newPage();
300+ try {
301+ await page.goto(`${BASE}/my-repo/patches/new`);
302+ await page.fill('[name=title]', 'Conflict patch');
303+ await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
304+ await page.click('form[action$="/patches"] button[type=submit]');
305+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
306+ conflictPatchUrl = page.url();
307+ } finally { await page.close(); }
308+ });
309+
310+ test('conflict patch shows apply-conflict status', async () => {
311+ const page = await adminCtx.newPage();
312+ try {
313+ await page.goto(conflictPatchUrl);
314+ expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
315+ } finally { await page.close(); }
316+ });
317+
318+ test('merge button absent for conflict patch', async () => {
319+ const page = await adminCtx.newPage();
320+ try {
321+ await page.goto(conflictPatchUrl);
322+ expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
323+ } finally { await page.close(); }
324+ });
325+
326+ test('merge clean patch changes status to merged', async () => {
327+ const page = await adminCtx.newPage();
328+ try {
329+ await page.goto(cleanPatchUrl);
330+ await page.click('form[action*="/merge"] button');
331+ await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
332+ expect(await page.locator('.patch-badge').textContent()).toBe('merged');
333+ } finally { await page.close(); }
334+ });
335+
336+ test('merge uses patch From header as git author', async () => {
337+ const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
338+ const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
339+ const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath);
340+ const subject = gitOutput(['log', '-1', '--format=%s'], repoPath);
341+ expect(authorName).toBe('Test User');
342+ expect(authorEmail).toBe('test@example.com');
343+ expect(subject).toBe('Add patch-test.txt');
344+ });
345+
346+ test('merged patch appears in merged list', async () => {
347+ const page = await adminCtx.newPage();
348+ try {
349+ await page.goto(`${BASE}/my-repo/patches?status=merged`);
350+ const titles = await page.locator('.issue-title').allTextContents();
351+ expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
352+ } finally { await page.close(); }
353+ });
354+
355+ test('upload and close a patch', async () => {
356+ writeTempFile('/tmp/close.patch', CLOSE_PATCH);
357+ const page = await adminCtx.newPage();
358+ try {
359+ await page.goto(`${BASE}/my-repo/patches/new`);
360+ await page.fill('[name=title]', 'Close me');
361+ await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
362+ await page.click('form[action$="/patches"] button[type=submit]');
363+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
364+ closePatchUrl = page.url();
365+ await page.click('form[action*="/close"] button');
366+ await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
367+ expect(await page.locator('.patch-badge').textContent()).toBe('closed');
368+ } finally { await page.close(); }
369+ });
370+
371+ test('closed patch appears in closed list', async () => {
372+ const page = await adminCtx.newPage();
373+ try {
374+ await page.goto(`${BASE}/my-repo/patches?status=closed`);
375+ const titles = await page.locator('.issue-title').allTextContents();
376+ expect(titles.some(t => t.includes('Close me'))).toBe(true);
377+ } finally { await page.close(); }
378+ });
379+
380+ test('closed patch can be reopened', async () => {
381+ const page = await adminCtx.newPage();
382+ try {
383+ await page.goto(closePatchUrl);
384+ expect(await page.locator('.patch-badge').textContent()).toBe('closed');
385+ await page.click('form[action*="/close"] button');
386+ await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
387+ expect(await page.locator('.patch-badge').textContent()).toBe('open');
388+ } finally { await page.close(); }
389+ });
390+
391+ test('patch title and description can be edited', async () => {
392+ const page = await adminCtx.newPage();
393+ try {
394+ await page.goto(conflictPatchUrl);
395+ // Edit title via title form
396+ await page.click('details.title-edit-details summary');
397+ await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
398+ await page.click('.title-edit-form-area [type=submit]');
399+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
400+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
401+ // Edit description via inline form
402+ await page.click('.timeline-author .inline-edit-details summary');
403+ await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
404+ await page.click('.inline-edit-form-area [type=submit]');
405+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
406+ } finally { await page.close(); }
407+ });
408+
409+ test('patch comment: add and edit', async () => {
410+ const page = await adminCtx.newPage();
411+ try {
412+ await page.goto(conflictPatchUrl);
413+ await page.fill('[name=body]', 'My patch comment');
414+ await page.click('form[action$="/comments"] button[type=submit]');
415+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
416+ expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
417+
418+ // Edit the comment — scope to the timeline-item containing the comment text
419+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
420+ await commentItem.locator('.inline-edit-details summary').click();
421+ await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
422+ await commentItem.locator('.inline-edit-form-area [type=submit]').click();
423+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
424+ expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
425+ } finally { await page.close(); }
426+ });
427+
428+ test('patch reaction on description', async () => {
429+ const page = await adminCtx.newPage();
430+ try {
431+ await page.goto(conflictPatchUrl);
432+ // Open reaction picker on the first timeline-item (description)
433+ await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
434+ await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
435+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
436+ expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
437+ } finally { await page.close(); }
438+ });
439+
440+ test('admin can delete a patch', async () => {
441+ const page = await adminCtx.newPage();
442+ try {
443+ const patchNum = conflictPatchUrl.split('/patches/')[1];
444+ const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
445+ maxRedirects: 0,
446+ });
447+ expect(resp.status()).toBe(302);
448+ expect(resp.headers()['location']).toContain('/patches');
449+ // Patch should no longer be accessible
450+ const checkResp = await page.request.get(conflictPatchUrl);
451+ expect(checkResp.status()).toBe(404);
452+ } finally { await page.close(); }
453+ });
454+
455+ // ── Patch file re-upload & version protection ──────────────────────────────
456+
457+ let uploadTestPatchUrl: string;
458+
459+ // Adds upload-test.txt — applies cleanly to my-repo
460+ const UPLOAD_TEST_PATCH = [
461+ 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
462+ 'From: Original Author <original@example.com>',
463+ 'Date: Wed, 03 Jan 2024 10:00:00 +0000',
464+ 'Subject: [PATCH] Add upload-test.txt',
465+ '',
466+ '---',
467+ 'diff --git a/upload-test.txt b/upload-test.txt',
468+ 'new file mode 100644',
469+ 'index 0000000..9daeafb',
470+ '--- /dev/null',
471+ '+++ b/upload-test.txt',
472+ '@@ -0,0 +1 @@',
473+ '+upload test',
474+ '',
475+ ].join('\n');
476+
477+ // Replacement: different author, same diff target
478+ const REPLACEMENT_PATCH = [
479+ 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
480+ 'From: Replaced Author <replaced@example.com>',
481+ 'Date: Thu, 04 Jan 2024 10:00:00 +0000',
482+ 'Subject: [PATCH] Add upload-test.txt (v2)',
483+ '',
484+ '---',
485+ 'diff --git a/upload-test.txt b/upload-test.txt',
486+ 'new file mode 100644',
487+ 'index 0000000..9daeafb',
488+ '--- /dev/null',
489+ '+++ b/upload-test.txt',
490+ '@@ -0,0 +1 @@',
491+ '+upload test v2',
492+ '',
493+ ].join('\n');
494+
495+ test('create patch for re-upload tests', async () => {
496+ writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
497+ const page = await adminCtx.newPage();
498+ try {
499+ await page.goto(`${BASE}/my-repo/patches/new`);
500+ await page.fill('[name=title]', 'Upload test patch');
501+ await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
502+ await page.click('form[action$="/patches"] button[type=submit]');
503+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
504+ uploadTestPatchUrl = page.url();
505+ } finally { await page.close(); }
506+ });
507+
508+ test('upload patch file button is visible for admin on open patch', async () => {
509+ const page = await adminCtx.newPage();
510+ try {
511+ await page.goto(uploadTestPatchUrl);
512+ expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
513+ } finally { await page.close(); }
514+ });
515+
516+ test('non-author non-admin cannot upload patch file', async () => {
517+ const aliceCtx = await loggedInContext('alice', 'password123');
518+ const page = await aliceCtx.newPage();
519+ try {
520+ const patchNum = uploadTestPatchUrl.split('/patches/')[1];
521+ const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
522+ multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
523+ maxRedirects: 0,
524+ });
525+ expect(resp.status()).toBe(403);
526+ } finally { await aliceCtx.close(); }
527+ });
528+
529+ test('upload button hidden for non-author non-admin', async () => {
530+ const aliceCtx = await loggedInContext('alice', 'password123');
531+ const page = await aliceCtx.newPage();
532+ try {
533+ await page.goto(uploadTestPatchUrl);
534+ expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
535+ } finally { await aliceCtx.close(); }
536+ });
537+
538+ test('admin can upload replacement patch file', async () => {
539+ writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
540+ const page = await adminCtx.newPage();
541+ try {
542+ await page.goto(uploadTestPatchUrl);
543+ await page.locator('details:has([name=patch_file]) summary').click();
544+ await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
545+ await page.locator('details:has([name=patch_file]) button[type=submit]').click();
546+ await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
547+ } finally { await page.close(); }
548+ });
549+
550+ test('merge fails when version token is stale', async () => {
551+ // Patch that adds stale-version.txt — applies cleanly
552+ const STALE_PATCH = [
553+ 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
554+ 'From: Test User <test@example.com>',
555+ 'Date: Fri, 05 Jan 2024 10:00:00 +0000',
556+ 'Subject: [PATCH] Add stale-version.txt',
557+ '',
558+ '---',
559+ 'diff --git a/stale-version.txt b/stale-version.txt',
560+ 'new file mode 100644',
561+ 'index 0000000..9daeafb',
562+ '--- /dev/null',
563+ '+++ b/stale-version.txt',
564+ '@@ -0,0 +1 @@',
565+ '+stale',
566+ '',
567+ ].join('\n');
568+ const STALE_PATCH_V2 = STALE_PATCH
569+ .replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
570+ .replace('+stale', '+stale v2');
571+
572+ writeTempFile('/tmp/stale.patch', STALE_PATCH);
573+ const page = await adminCtx.newPage();
574+ try {
575+ // Create the patch
576+ await page.goto(`${BASE}/my-repo/patches/new`);
577+ await page.fill('[name=title]', 'Stale version test');
578+ await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
579+ await page.click('form[action$="/patches"] button[type=submit]');
580+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
581+ const stalePatchUrl = page.url();
582+ const patchNum = stalePatchUrl.split('/patches/')[1];
583+
584+ // Capture the version the admin sees on the page
585+ const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
586+
587+ // Author uploads a new patch file (simulated by admin here), bumping the version
588+ writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
589+ await page.locator('details:has([name=patch_file]) summary').click();
590+ await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
591+ await page.locator('details:has([name=patch_file]) button[type=submit]').click();
592+ await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
593+
594+ // Admin tries to merge with the stale version — should be rejected
595+ const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
596+ form: { version: staleVersion },
597+ maxRedirects: 0,
598+ });
599+ expect(resp.status()).toBe(409);
600+ expect(await resp.text()).toContain('updated');
601+
602+ // Patch status must still be open
603+ const checkResp = await page.request.get(stalePatchUrl);
604+ expect(checkResp.status()).toBe(200);
605+ expect(await checkResp.text()).toContain('open');
606+ } finally { await page.close(); }
607+ });
608+
609+ test('merge succeeds with current version token after replacement upload', async () => {
610+ const page = await adminCtx.newPage();
611+ try {
612+ await page.goto(uploadTestPatchUrl);
613+ await page.click('form[action*="/merge"] button');
614+ await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
615+ expect(await page.locator('.patch-badge').textContent()).toBe('merged');
616+ // Merged commit should carry the replacement patch's author
617+ const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
618+ const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
619+ expect(authorName).toBe('Replaced Author');
620+ } finally { await page.close(); }
621+ });
622+
623+ test('upload patch file button hidden on merged patch', async () => {
624+ const page = await adminCtx.newPage();
625+ try {
626+ await page.goto(uploadTestPatchUrl);
627+ expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
628+ } finally { await page.close(); }
629+ });
630+
631+ test('POST to upload on merged patch returns 400', async () => {
632+ const patchNum = uploadTestPatchUrl.split('/patches/')[1];
633+ const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
634+ multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
635+ maxRedirects: 0,
636+ });
637+ expect(resp.status()).toBe(400);
638+ });
639+});
640+
641+// ─── Commit signing ───────────────────────────────────────────────────────────
642+// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
643+
644+describe('commit signing', () => {
645+ let adminCtx: BrowserContext;
646+
647+ beforeAll(async () => { adminCtx = await loggedInContext(); });
648+ afterAll(async () => { await adminCtx.close(); });
649+
650+ test('allowed_signers file is generated at startup', () => {
651+ const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
652+ expect(existsSync(allowedSignersPath)).toBe(true);
653+ const content = readFileSync(allowedSignersPath, 'utf8');
654+ expect(content).toContain('namespaces="git"');
655+ expect(content).toContain('ssh-ed25519');
656+ });
657+
658+ test('merged commit has a gpgsig header', async () => {
659+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
660+ // Find the patch commit specifically by subject
661+ const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
662+ expect(hash).toBeTruthy();
663+ const obj = gitOutput(['cat-file', '-p', hash], repoDir);
664+ expect(obj).toContain('gpgsig');
665+ });
666+
667+ test('unsigned commits have no gpgsig header', async () => {
668+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
669+ // Initial commit was created by seedRepo (plain git commit, not hearthforge)
670+ const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
671+ expect(hash).toBeTruthy();
672+ const obj = gitOutput(['cat-file', '-p', hash], repoDir);
673+ expect(obj).not.toContain('gpgsig');
674+ });
675+
676+ test('commit log shows verified badge on signed commit', async () => {
677+ const page = await adminCtx.newPage();
678+ try {
679+ await page.goto(`${BASE}/my-repo/commits/main`);
680+ // Find the commit-item for the merged patch by subject text
681+ const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
682+ expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
683+ } finally { await page.close(); }
684+ });
685+
686+ test('commit log shows no sig badge on unsigned commit', async () => {
687+ const page = await adminCtx.newPage();
688+ try {
689+ await page.goto(`${BASE}/my-repo/commits/main`);
690+ // Initial commit was not signed via hearthforge
691+ const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
692+ expect(await initialItem.locator('.sig-badge').count()).toBe(0);
693+ } finally { await page.close(); }
694+ });
695+
696+ test('commit detail shows verified signature row for signed commit', async () => {
697+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
698+ const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
699+ const page = await adminCtx.newPage();
700+ try {
701+ await page.goto(`${BASE}/my-repo/commit/${hash}`);
702+ const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
703+ expect(await sigRow.isVisible()).toBe(true);
704+ expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
705+ } finally { await page.close(); }
706+ });
707+
708+ test('commit detail shows no signature row for unsigned commit', async () => {
709+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
710+ const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
711+ const page = await adminCtx.newPage();
712+ try {
713+ await page.goto(`${BASE}/my-repo/commit/${hash}`);
714+ const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
715+ expect(await sigRow.count()).toBe(0);
716+ } finally { await page.close(); }
717+ });
718+});
Atests/e2e.releases.test.ts
@@ -0,0 +1,361 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ seedRepo,
12+} from './helpers.ts';
13+
14+let browser: Browser;
15+let server: Awaited<ReturnType<typeof spawnServer>>;
16+
17+beforeAll(async () => {
18+ await setupTestEnv();
19+ server = await spawnServer();
20+ browser = await chromium.launch();
21+
22+ // Register alice
23+ const regCtx = await browser.newContext();
24+ const regPage = await regCtx.newPage();
25+ try {
26+ await regPage.goto(`${BASE}/register`);
27+ await regPage.fill('[name=username]', 'alice');
28+ await regPage.fill('[name=password]', 'password123');
29+ await regPage.fill('[name=password2]', 'password123');
30+ await regPage.click('button[type=submit]');
31+ await regPage.waitForURL(BASE + '/');
32+ } finally { await regCtx.close(); }
33+});
34+
35+afterAll(async () => {
36+ await browser.close();
37+ await killServer(server);
38+});
39+
40+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
41+ const ctx = await browser.newContext();
42+ const page = await ctx.newPage();
43+ await login(page, username, password);
44+ await page.close();
45+ return ctx;
46+}
47+
48+// ─── Releases ─────────────────────────────────────────────────────────────────
49+
50+describe('releases', () => {
51+ let adminCtx: BrowserContext;
52+ let aliceCtx: BrowserContext;
53+ let releaseUrl: string;
54+ let srcReleaseUrl: string;
55+ let releaseWithAssetsUrl: string;
56+
57+ beforeAll(async () => {
58+ adminCtx = await loggedInContext();
59+ aliceCtx = await loggedInContext('alice', 'password123');
60+
61+ // Create a dedicated repo with at least one commit
62+ const page = await adminCtx.newPage();
63+ try {
64+ await page.goto(`${BASE}/new`);
65+ await page.fill('[name=name]', 'releases-repo');
66+ await page.click('form[action="/new"] button[type=submit]');
67+ await page.waitForURL(`${BASE}/releases-repo`);
68+ } finally { await page.close(); }
69+
70+ await seedRepo('releases-repo');
71+ });
72+
73+ afterAll(async () => {
74+ await adminCtx.close();
75+ await aliceCtx.close();
76+ });
77+
78+ // ── Navigation ──────────────────────────────────────────────────────────────
79+
80+ test('releases tab visible in repo nav', async () => {
81+ const page = await adminCtx.newPage();
82+ try {
83+ await page.goto(`${BASE}/releases-repo`);
84+ expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
85+ } finally { await page.close(); }
86+ });
87+
88+ test('releases list shows empty state when no releases', async () => {
89+ const page = await adminCtx.newPage();
90+ try {
91+ await page.goto(`${BASE}/releases-repo/releases`);
92+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
93+ } finally { await page.close(); }
94+ });
95+
96+ // ── Access control ──────────────────────────────────────────────────────────
97+
98+ test('non-admin cannot access /releases/new', async () => {
99+ const page = await aliceCtx.newPage();
100+ try {
101+ const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
102+ expect(resp.status()).toBe(403);
103+ } finally { await page.close(); }
104+ });
105+
106+ test('non-admin POST to /releases returns 403', async () => {
107+ const page = await aliceCtx.newPage();
108+ try {
109+ const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
110+ multipart: { name: 'Test', tag_name: 'v0.1.0' },
111+ maxRedirects: 0,
112+ });
113+ expect(resp.status()).toBe(403);
114+ } finally { await page.close(); }
115+ });
116+
117+ test('unauthenticated user is redirected to login from /releases/new', async () => {
118+ const ctx = await browser.newContext();
119+ const page = await ctx.newPage();
120+ try {
121+ await page.goto(`${BASE}/releases-repo/releases/new`);
122+ expect(page.url()).toContain('/login');
123+ } finally { await ctx.close(); }
124+ });
125+
126+ // ── Validation ──────────────────────────────────────────────────────────────
127+
128+ test('missing release title shows error', async () => {
129+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
130+ multipart: {},
131+ });
132+ expect(await resp.text()).toContain('Release title is required');
133+ });
134+
135+ test('create_tag checked but no tag name shows error', async () => {
136+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
137+ multipart: { name: 'Test', create_tag: 'on' },
138+ });
139+ expect(await resp.text()).toContain('Tag name is required');
140+ });
141+
142+ // ── Create ──────────────────────────────────────────────────────────────────
143+
144+ test('create a basic release', async () => {
145+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
146+ multipart: {
147+ create_tag: 'on',
148+ tag_name: 'v1.0.0',
149+ revision: 'main',
150+ name: 'First release',
151+ notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
152+ },
153+ maxRedirects: 0,
154+ });
155+ expect(resp.status()).toBe(302);
156+ const location = resp.headers()['location']!;
157+ expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
158+ releaseUrl = `${BASE}${location}`;
159+ });
160+
161+ test('duplicate tag name shows error', async () => {
162+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
163+ multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
164+ });
165+ expect(await resp.text()).toContain('already exists');
166+ });
167+
168+ // ── List ────────────────────────────────────────────────────────────────────
169+
170+ test('release appears in list with tag badge', async () => {
171+ const page = await adminCtx.newPage();
172+ try {
173+ await page.goto(`${BASE}/releases-repo/releases`);
174+ expect(await page.locator('.release-item-title').textContent()).toContain('First release');
175+ expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
176+ } finally { await page.close(); }
177+ });
178+
179+ test('release list shows tag badge', async () => {
180+ const page = await adminCtx.newPage();
181+ try {
182+ await page.goto(`${BASE}/releases-repo/releases`);
183+ expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
184+ } finally { await page.close(); }
185+ });
186+
187+ test('new release button hidden for non-admin', async () => {
188+ const page = await aliceCtx.newPage();
189+ try {
190+ await page.goto(`${BASE}/releases-repo/releases`);
191+ expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
192+ } finally { await page.close(); }
193+ });
194+
195+ // ── Detail ──────────────────────────────────────────────────────────────────
196+
197+ test('release detail shows title and tag badge', async () => {
198+ const page = await adminCtx.newPage();
199+ try {
200+ await page.goto(releaseUrl);
201+ expect(await page.locator('h2.page-title').textContent()).toBe('First release');
202+ expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
203+ } finally { await page.close(); }
204+ });
205+
206+ test('release notes rendered in detail view', async () => {
207+ const page = await adminCtx.newPage();
208+ try {
209+ await page.goto(releaseUrl);
210+ expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
211+ } finally { await page.close(); }
212+ });
213+
214+ // ── Source archives ─────────────────────────────────────────────────────────
215+
216+ test('create release with source code archives', async () => {
217+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
218+ multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
219+ maxRedirects: 0,
220+ });
221+ expect(resp.status()).toBe(302);
222+ const location = resp.headers()['location']!;
223+ srcReleaseUrl = `${BASE}${location}`;
224+ });
225+
226+ test('zip and tar.gz archives appear in downloads', async () => {
227+ const page = await adminCtx.newPage();
228+ try {
229+ await page.goto(srcReleaseUrl);
230+ const assetNames = await page.locator('.asset-name').allTextContents();
231+ expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
232+ expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
233+ } finally { await page.close(); }
234+ });
235+
236+ test('source archive download responds with 200', async () => {
237+ const page = await adminCtx.newPage();
238+ try {
239+ await page.goto(srcReleaseUrl);
240+ const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
241+ const resp = await page.request.get(`${BASE}${zipLink}`);
242+ expect(resp.status()).toBe(200);
243+ } finally { await page.close(); }
244+ });
245+
246+ // ── File upload ─────────────────────────────────────────────────────────────
247+
248+ test('create release with attached file', async () => {
249+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
250+ multipart: {
251+ name: 'Asset release',
252+ create_tag: 'on',
253+ tag_name: 'v1.2.0',
254+ revision: 'main',
255+ files: {
256+ name: 'release-asset.txt',
257+ mimeType: 'text/plain',
258+ buffer: Buffer.from('binary-like content for testing\n'),
259+ },
260+ },
261+ maxRedirects: 0,
262+ });
263+ expect(resp.status()).toBe(302);
264+ const location = resp.headers()['location']!;
265+ releaseWithAssetsUrl = `${BASE}${location}`;
266+ });
267+
268+ test('uploaded asset appears in downloads with filename and size', async () => {
269+ const page = await adminCtx.newPage();
270+ try {
271+ await page.goto(releaseWithAssetsUrl);
272+ const names = await page.locator('.asset-name').allTextContents();
273+ expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
274+ expect(await page.locator('.asset-size').isVisible()).toBe(true);
275+ } finally { await page.close(); }
276+ });
277+
278+ test('asset download responds with 200', async () => {
279+ const page = await adminCtx.newPage();
280+ try {
281+ await page.goto(releaseWithAssetsUrl);
282+ const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
283+ const resp = await page.request.get(`${BASE}${link}`);
284+ expect(resp.status()).toBe(200);
285+ } finally { await page.close(); }
286+ });
287+
288+ // ── Delete ──────────────────────────────────────────────────────────────────
289+
290+ test('non-admin cannot delete a release', async () => {
291+ const page = await aliceCtx.newPage();
292+ try {
293+ const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
294+ const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
295+ maxRedirects: 0,
296+ });
297+ expect(resp.status()).toBe(403);
298+ } finally { await page.close(); }
299+ });
300+
301+ test('admin can delete a release', async () => {
302+ const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
303+ const resp = await adminCtx.request.post(
304+ `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
305+ { maxRedirects: 0 },
306+ );
307+ expect(resp.status()).toBe(302);
308+ // Verify it's gone from the list
309+ const page = await adminCtx.newPage();
310+ try {
311+ await page.goto(`${BASE}/releases-repo/releases`);
312+ const titles = await page.locator('.release-item-title').allTextContents();
313+ expect(titles.some(t => t.includes('First release'))).toBe(false);
314+ } finally { await page.close(); }
315+ });
316+
317+ // ── Pagination ──────────────────────────────────────────────────────────────
318+
319+ test('release list shows at most 20 per page', async () => {
320+ // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
321+ // regardless of which browser-based tests above succeeded
322+ for (let i = 1; i <= 25; i++) {
323+ await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
324+ multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
325+ maxRedirects: 0,
326+ }).catch(() => {});
327+ }
328+ const page = await adminCtx.newPage();
329+ try {
330+ await page.goto(`${BASE}/releases-repo/releases`);
331+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
332+ } finally { await page.close(); }
333+ });
334+
335+ test('pagination nav appears with more than 20 releases', async () => {
336+ const page = await adminCtx.newPage();
337+ try {
338+ await page.goto(`${BASE}/releases-repo/releases`);
339+ expect(await page.locator('.pagination').isVisible()).toBe(true);
340+ } finally { await page.close(); }
341+ });
342+
343+ test('release list page 2 shows remaining releases', async () => {
344+ const page = await adminCtx.newPage();
345+ try {
346+ await page.goto(`${BASE}/releases-repo/releases?page=2`);
347+ const count = await page.locator('.issue-item').count();
348+ expect(count).toBeGreaterThan(0);
349+ expect(count).toBeLessThanOrEqual(20);
350+ } finally { await page.close(); }
351+ });
352+
353+ test('source archive tar.zst appears in downloads', async () => {
354+ const page = await adminCtx.newPage();
355+ try {
356+ await page.goto(srcReleaseUrl);
357+ const assetNames = await page.locator('.asset-name').allTextContents();
358+ expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
359+ } finally { await page.close(); }
360+ });
361+});
Atests/e2e.repos.test.ts
@@ -0,0 +1,343 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+ seedRepo,
12+} from './helpers.ts';
13+
14+let browser: Browser;
15+let server: Awaited<ReturnType<typeof spawnServer>>;
16+
17+beforeAll(async () => {
18+ await setupTestEnv();
19+ server = await spawnServer();
20+ browser = await chromium.launch();
21+
22+ // Register alice
23+ const ctx = await browser.newContext();
24+ const page = await ctx.newPage();
25+ try {
26+ await page.goto(`${BASE}/register`);
27+ await page.fill('[name=username]', 'alice');
28+ await page.fill('[name=password]', 'password123');
29+ await page.fill('[name=password2]', 'password123');
30+ await page.click('button[type=submit]');
31+ await page.waitForURL(BASE + '/');
32+ } finally { await ctx.close(); }
33+});
34+
35+afterAll(async () => {
36+ await browser.close();
37+ await killServer(server);
38+});
39+
40+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
41+ const ctx = await browser.newContext();
42+ const page = await ctx.newPage();
43+ await login(page, username, password);
44+ await page.close();
45+ return ctx;
46+}
47+
48+// ─── Repos ────────────────────────────────────────────────────────────────────
49+
50+describe('repos', () => {
51+ // Shared admin context — cookies persist across tests in this block.
52+ let adminCtx: BrowserContext;
53+ // Alice's context, created after alice is registered in auth tests.
54+ let aliceCtx: BrowserContext;
55+ // Set after 'commit log' test; used by all commit-detail tests below.
56+ let commitUrl: string;
57+
58+ beforeAll(async () => {
59+ adminCtx = await loggedInContext();
60+ aliceCtx = await loggedInContext('alice', 'password123');
61+ });
62+
63+ afterAll(async () => {
64+ await adminCtx.close();
65+ await aliceCtx.close();
66+ });
67+
68+ test('non-admin gets 403 on /new', async () => {
69+ const page = await aliceCtx.newPage();
70+ try {
71+ const resp = await page.request.get(`${BASE}/new`);
72+ expect(resp.status()).toBe(403);
73+ } finally { await page.close(); }
74+ });
75+
76+ test('create repository', async () => {
77+ const page = await adminCtx.newPage();
78+ try {
79+ await page.goto(`${BASE}/new`);
80+ await page.fill('[name=name]', 'my-repo');
81+ await page.fill('[name=description]', 'A test repo');
82+ await page.click('form[action="/new"] button[type=submit]');
83+ await page.waitForURL(`${BASE}/my-repo`);
84+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
85+ } finally { await page.close(); }
86+ });
87+
88+ test('repository appears in list', async () => {
89+ const page = await adminCtx.newPage();
90+ try {
91+ await page.goto(BASE);
92+ expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
93+ } finally { await page.close(); }
94+ });
95+
96+ test('search finds matching repo', async () => {
97+ const page = await adminCtx.newPage();
98+ try {
99+ await page.goto(BASE);
100+ await page.fill('[name=q]', 'my-repo');
101+ await page.click('.search-form button[type=submit]');
102+ expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
103+ } finally { await page.close(); }
104+ });
105+
106+ test('search returns empty for unknown term', async () => {
107+ const page = await adminCtx.newPage();
108+ try {
109+ await page.goto(BASE);
110+ await page.fill('[name=q]', 'zzz-nothing-here');
111+ await page.click('.search-form button[type=submit]');
112+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
113+ } finally { await page.close(); }
114+ });
115+
116+ test('browse file tree after seeding content', async () => {
117+ await seedRepo('my-repo');
118+ const page = await adminCtx.newPage();
119+ try {
120+ await page.goto(`${BASE}/my-repo/tree/main`);
121+ const files = await page.locator('.file-name a').allTextContents();
122+ expect(files).toContain('README.md');
123+ expect(files).toContain('index.js');
124+ } finally { await page.close(); }
125+ });
126+
127+ test('view file blob with syntax highlighting', async () => {
128+ const page = await adminCtx.newPage();
129+ try {
130+ await page.goto(`${BASE}/my-repo/blob/main/index.js`);
131+ expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
132+ expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
133+ } finally { await page.close(); }
134+ });
135+
136+ test('raw file download responds 200', async () => {
137+ const page = await adminCtx.newPage();
138+ try {
139+ const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
140+ expect(resp.status()).toBe(200);
141+ expect(resp.headers()['content-disposition']).toContain('README.md');
142+ } finally { await page.close(); }
143+ });
144+
145+ test('commit log shows initial commit', async () => {
146+ const page = await adminCtx.newPage();
147+ try {
148+ await page.goto(`${BASE}/my-repo/commits/main`);
149+ const subjects = await page.locator('.commit-subject').allTextContents();
150+ expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
151+ // Navigate to the commit page and capture the URL for subsequent tests
152+ await page.locator('.commit-hash').first().click();
153+ await page.waitForURL(/\/my-repo\/commit\//);
154+ commitUrl = page.url();
155+ } finally { await page.close(); }
156+ });
157+
158+ test('commit detail shows metadata card', async () => {
159+ const page = await adminCtx.newPage();
160+ try {
161+ await page.goto(commitUrl);
162+ expect(await page.locator('.commit-card').isVisible()).toBe(true);
163+ expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
164+ // Author, date and SHA rows are all present
165+ const metaText = await page.locator('.commit-card-meta').textContent();
166+ expect(metaText).toContain('Test'); // author name set by seedRepo
167+ expect(metaText).toContain('Author'); // label (CSS uppercases visually)
168+ expect(metaText).toContain('Date');
169+ expect(metaText).toContain('Commit');
170+ } finally { await page.close(); }
171+ });
172+
173+ test('commit detail full SHA is shown', async () => {
174+ const page = await adminCtx.newPage();
175+ try {
176+ await page.goto(commitUrl);
177+ const sha = commitUrl.split('/commit/')[1] ?? null;
178+ expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
179+ } finally { await page.close(); }
180+ });
181+
182+ test('commit detail shows file nav sidebar', async () => {
183+ const page = await adminCtx.newPage();
184+ try {
185+ await page.goto(commitUrl);
186+ expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
187+ const navItems = await page.locator('.file-nav-item').allTextContents();
188+ // seedRepo adds README.md and index.js
189+ expect(navItems.some(t => t.includes('README.md'))).toBe(true);
190+ expect(navItems.some(t => t.includes('index.js'))).toBe(true);
191+ } finally { await page.close(); }
192+ });
193+
194+ test('commit detail file nav items are anchor links to diff sections', async () => {
195+ const page = await adminCtx.newPage();
196+ try {
197+ await page.goto(commitUrl);
198+ const hrefs = await page.locator('.file-nav-item').evaluateAll(
199+ els => els.map(el => el.getAttribute('href') ?? ''),
200+ );
201+ expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
202+ } finally { await page.close(); }
203+ });
204+
205+ test('commit detail shows diff table with added lines', async () => {
206+ const page = await adminCtx.newPage();
207+ try {
208+ await page.goto(commitUrl);
209+ // Initial commit only adds lines
210+ expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
211+ expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
212+ expect(await page.locator('.diff-row-del').count()).toBe(0);
213+ } finally { await page.close(); }
214+ });
215+
216+ test('commit detail diff table has line numbers', async () => {
217+ const page = await adminCtx.newPage();
218+ try {
219+ await page.goto(commitUrl);
220+ // New-side line numbers (column 2) on add rows start at 1
221+ const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
222+ expect(firstNewLn?.trim()).toBe('1');
223+ } finally { await page.close(); }
224+ });
225+
226+ test('commit detail shows added stats on file header', async () => {
227+ const page = await adminCtx.newPage();
228+ try {
229+ await page.goto(commitUrl);
230+ const addStats = await page.locator('.diff-stat-add').allTextContents();
231+ expect(addStats.length).toBeGreaterThan(0);
232+ expect(addStats.every(s => s.startsWith('+'))).toBe(true);
233+ } finally { await page.close(); }
234+ });
235+
236+ test('commit detail view-at-sha button links to blob at that commit', async () => {
237+ const page = await adminCtx.newPage();
238+ try {
239+ await page.goto(commitUrl);
240+ const sha = commitUrl.split('/commit/')[1];
241+ const btn = page.locator('.btn-xs').first();
242+ const href = await btn.getAttribute('href');
243+ expect(href).toContain(`/blob/${sha}/`);
244+ } finally { await page.close(); }
245+ });
246+
247+ test('commit detail view-at-branch button links to blob at default branch', async () => {
248+ const page = await adminCtx.newPage();
249+ try {
250+ await page.goto(commitUrl);
251+ const btns = await page.locator('.btn-xs').allTextContents();
252+ expect(btns.some(t => t.includes('@ main'))).toBe(true);
253+ const branchBtns = await page.locator('.btn-xs').evaluateAll(
254+ els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
255+ );
256+ expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
257+ } finally { await page.close(); }
258+ });
259+
260+ test('commit detail file diff can be collapsed', async () => {
261+ const page = await adminCtx.newPage();
262+ try {
263+ await page.goto(commitUrl);
264+ // File body is visible when details is open
265+ const diffFile = page.locator('.diff-file').first();
266+ expect(await diffFile.getAttribute('open')).not.toBeNull();
267+ // Click the summary to collapse
268+ await diffFile.locator('.diff-file-header').click();
269+ expect(await diffFile.getAttribute('open')).toBeNull();
270+ } finally { await page.close(); }
271+ });
272+
273+ test('commit detail file nav sidebar can be collapsed', async () => {
274+ const page = await adminCtx.newPage();
275+ try {
276+ await page.goto(commitUrl);
277+ const nav = page.locator('.file-nav-details');
278+ expect(await nav.getAttribute('open')).not.toBeNull();
279+ await nav.locator('.file-nav-toggle').click();
280+ expect(await nav.getAttribute('open')).toBeNull();
281+ } finally { await page.close(); }
282+ });
283+
284+ test('readme renders on repo home', async () => {
285+ const page = await adminCtx.newPage();
286+ try {
287+ await page.goto(`${BASE}/my-repo`);
288+ expect(await page.locator('.readme-header').isVisible()).toBe(true);
289+ expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
290+ } finally { await page.close(); }
291+ });
292+
293+ test('private repo hidden from other users', async () => {
294+ // Make private
295+ const adminPage = await adminCtx.newPage();
296+ try {
297+ await adminPage.goto(`${BASE}/my-repo/settings`);
298+ await adminPage.check('[name=is_private]');
299+ await adminPage.click('form[action$="/settings"] button[type=submit]');
300+ expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
301+ } finally { await adminPage.close(); }
302+
303+ // Alice should get 404
304+ const alicePage = await aliceCtx.newPage();
305+ try {
306+ const resp = await alicePage.request.get(`${BASE}/my-repo`);
307+ expect(resp.status()).toBe(404);
308+ await alicePage.goto(BASE);
309+ expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
310+ } finally { await alicePage.close(); }
311+
312+ // Restore to public
313+ const adminPage2 = await adminCtx.newPage();
314+ try {
315+ await adminPage2.goto(`${BASE}/my-repo/settings`);
316+ await adminPage2.uncheck('[name=is_private]');
317+ await adminPage2.click('form[action$="/settings"] button[type=submit]');
318+ } finally { await adminPage2.close(); }
319+ });
320+
321+ test('settings tab visible for admin, hidden for others', async () => {
322+ const adminPage = await adminCtx.newPage();
323+ try {
324+ await adminPage.goto(`${BASE}/my-repo`);
325+ expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
326+ } finally { await adminPage.close(); }
327+
328+ const alicePage = await aliceCtx.newPage();
329+ try {
330+ await alicePage.goto(`${BASE}/my-repo`);
331+ expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
332+ } finally { await alicePage.close(); }
333+ });
334+
335+ test('create repository with invalid name shows error', async () => {
336+ const resp = await adminCtx.request.post(`${BASE}/new`, {
337+ form: { name: 'has spaces!', description: '', default_branch: 'main' },
338+ maxRedirects: 0,
339+ });
340+ expect(resp.status()).toBe(200);
341+ expect(await resp.text()).toContain('Invalid repository name');
342+ });
343+});
Atests/e2e.settings.test.ts
@@ -0,0 +1,396 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import { rmSync, readFileSync } from 'node:fs';
5+import { spawnSync as nodeSpawnSync } from 'node:child_process';
6+import {
7+ BASE,
8+ ADMIN_PASS,
9+ setupTestEnv,
10+ spawnServer,
11+ killServer,
12+ login,
13+ seedRepo,
14+} from './helpers.ts';
15+
16+let browser: Browser;
17+let server: Awaited<ReturnType<typeof spawnServer>>;
18+
19+beforeAll(async () => {
20+ await setupTestEnv();
21+ server = await spawnServer();
22+ browser = await chromium.launch();
23+
24+ // Register alice
25+ const regCtx = await browser.newContext();
26+ const regPage = await regCtx.newPage();
27+ try {
28+ await regPage.goto(`${BASE}/register`);
29+ await regPage.fill('[name=username]', 'alice');
30+ await regPage.fill('[name=password]', 'password123');
31+ await regPage.fill('[name=password2]', 'password123');
32+ await regPage.click('button[type=submit]');
33+ await regPage.waitForURL(BASE + '/');
34+ } finally { await regCtx.close(); }
35+
36+ // Create my-repo (needed for repo deletion non-admin test)
37+ const adminCtx = await browser.newContext();
38+ const adminPage = await adminCtx.newPage();
39+ try {
40+ await login(adminPage);
41+ await adminPage.goto(`${BASE}/new`);
42+ await adminPage.fill('[name=name]', 'my-repo');
43+ await adminPage.click('form[action="/new"] button[type=submit]');
44+ await adminPage.waitForURL(`${BASE}/my-repo`);
45+ } finally { await adminCtx.close(); }
46+
47+ await seedRepo('my-repo');
48+});
49+
50+afterAll(async () => {
51+ await browser.close();
52+ await killServer(server);
53+});
54+
55+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
56+ const ctx = await browser.newContext();
57+ const page = await ctx.newPage();
58+ await login(page, username, password);
59+ await page.close();
60+ return ctx;
61+}
62+
63+// ─── Settings ─────────────────────────────────────────────────────────────────
64+
65+describe('settings', () => {
66+ let adminCtx: BrowserContext;
67+ let aliceCtx: BrowserContext;
68+ // Public key generated once in beforeAll, reused across SSH key tests
69+ let testPubKey: string;
70+
71+ beforeAll(async () => {
72+ adminCtx = await loggedInContext();
73+ aliceCtx = await loggedInContext('alice', 'password123');
74+
75+ // Generate a throwaway ed25519 key for SSH key tests.
76+ const keyPath = '/tmp/hf-e2e-sshkey';
77+ rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
78+ nodeSpawnSync(
79+ 'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
80+ { stdio: 'ignore' },
81+ );
82+ testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim();
83+ rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
84+ });
85+
86+ afterAll(async () => {
87+ await adminCtx.close();
88+ await aliceCtx.close();
89+ });
90+
91+ test('settings page requires auth', async () => {
92+ const ctx = await browser.newContext();
93+ const page = await ctx.newPage();
94+ try {
95+ await page.goto(`${BASE}/settings`);
96+ expect(page.url()).toContain('/login');
97+ } finally { await ctx.close(); }
98+ });
99+
100+ test('settings page loads for logged-in user', async () => {
101+ const page = await adminCtx.newPage();
102+ try {
103+ await page.goto(`${BASE}/settings`);
104+ expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
105+ } finally { await page.close(); }
106+ });
107+
108+ // ── Password ──────────────────────────────────────────────────────────────
109+
110+ test('password change with mismatched passwords shows error', async () => {
111+ const page = await aliceCtx.newPage();
112+ try {
113+ await page.goto(`${BASE}/settings`);
114+ await page.fill('[name=new_password]', 'newpass123');
115+ await page.fill('[name=confirm_password]', 'different456');
116+ await page.click('form[action="/settings/password"] button[type=submit]');
117+ await page.waitForURL(/\/settings/);
118+ expect(page.url()).toContain('error');
119+ } finally { await page.close(); }
120+ });
121+
122+ test('password change with wrong current password shows error', async () => {
123+ const page = await aliceCtx.newPage();
124+ try {
125+ await page.goto(`${BASE}/settings`);
126+ await page.fill('[name=current_password]', 'wrongpassword');
127+ await page.fill('[name=new_password]', 'newpass123');
128+ await page.fill('[name=confirm_password]', 'newpass123');
129+ await page.click('form[action="/settings/password"] button[type=submit]');
130+ await page.waitForURL(/\/settings/);
131+ expect(page.url()).toContain('error');
132+ } finally { await page.close(); }
133+ });
134+
135+ test('password change too short shows error', async () => {
136+ const page = await aliceCtx.newPage();
137+ try {
138+ await page.goto(`${BASE}/settings`);
139+ await page.fill('[name=current_password]', 'password123');
140+ await page.fill('[name=new_password]', 'short');
141+ await page.fill('[name=confirm_password]', 'short');
142+ await page.click('form[action="/settings/password"] button[type=submit]');
143+ await page.waitForURL(/\/settings/);
144+ expect(page.url()).toContain('error');
145+ } finally { await page.close(); }
146+ });
147+
148+ // ── SSH keys ──────────────────────────────────────────────────────────────
149+
150+ test('add SSH key with unsupported key type shows error', async () => {
151+ const page = await adminCtx.newPage();
152+ try {
153+ await page.goto(`${BASE}/settings`);
154+ await page.fill('#ssh_key_name', 'Bad key');
155+ await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
156+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
157+ await page.waitForURL(/\/settings/);
158+ expect(page.url()).toContain('error');
159+ } finally { await page.close(); }
160+ });
161+
162+ test('add valid SSH key shows success and key appears in list', async () => {
163+ const page = await adminCtx.newPage();
164+ try {
165+ await page.goto(`${BASE}/settings`);
166+ await page.fill('#ssh_key_name', 'My Laptop');
167+ await page.fill('#ssh_public_key', testPubKey);
168+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
169+ await page.waitForURL(/\/settings/);
170+ expect(page.url()).toContain('success=ssh_key_added');
171+ await page.goto(`${BASE}/settings`);
172+ expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
173+ } finally { await page.close(); }
174+ });
175+
176+ test('add duplicate SSH key shows error', async () => {
177+ const page = await adminCtx.newPage();
178+ try {
179+ await page.goto(`${BASE}/settings`);
180+ await page.fill('#ssh_key_name', 'Duplicate');
181+ await page.fill('#ssh_public_key', testPubKey);
182+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
183+ await page.waitForURL(/\/settings/);
184+ expect(page.url()).toContain('error');
185+ } finally { await page.close(); }
186+ });
187+
188+ test('delete SSH key removes it from list', async () => {
189+ const page = await adminCtx.newPage();
190+ try {
191+ await page.goto(`${BASE}/settings`);
192+ // Click the Remove button for the key added above
193+ await page.click('form[action="/settings/ssh-keys/delete"] button');
194+ await page.waitForURL(/\/settings/);
195+ expect(page.url()).toContain('success=ssh_key_deleted');
196+ await page.goto(`${BASE}/settings`);
197+ expect(await page.locator('.ssh-key-name').count()).toBe(0);
198+ } finally { await page.close(); }
199+ });
200+
201+ // ── Admin user management ────────────────────────────────────────────────
202+
203+ test('admin can create a new user account', async () => {
204+ const page = await adminCtx.newPage();
205+ try {
206+ await page.goto(`${BASE}/settings`);
207+ await page.fill('#new_username', 'charlie');
208+ await page.fill('#new_user_password', 'charliepw1');
209+ await page.click('form[action="/admin/users"] button[type=submit]');
210+ await page.waitForURL(/\/settings/);
211+ expect(page.url()).toContain('success=user_created');
212+ } finally { await page.close(); }
213+ });
214+
215+ test('admin cannot create duplicate username', async () => {
216+ const page = await adminCtx.newPage();
217+ try {
218+ await page.goto(`${BASE}/settings`);
219+ await page.fill('#new_username', 'charlie');
220+ await page.fill('#new_user_password', 'charliepw1');
221+ await page.click('form[action="/admin/users"] button[type=submit]');
222+ await page.waitForURL(/\/settings/);
223+ expect(page.url()).toContain('error');
224+ } finally { await page.close(); }
225+ });
226+
227+ test('admin cannot create user with invalid username characters', async () => {
228+ const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
229+ form: { username: 'bad user!', password: 'password123' },
230+ maxRedirects: 0,
231+ });
232+ expect(resp.status()).toBe(302);
233+ const location = resp.headers()['location'] ?? '';
234+ expect(location).toContain('error');
235+ });
236+
237+ test('non-admin gets 403 when creating user', async () => {
238+ const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
239+ form: { username: 'hacker', password: 'password123' },
240+ maxRedirects: 0,
241+ });
242+ expect(resp.status()).toBe(403);
243+ });
244+
245+ test('admin can delete user account', async () => {
246+ const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
247+ form: { username: 'charlie' },
248+ maxRedirects: 0,
249+ });
250+ expect(resp.status()).toBe(302);
251+ expect(resp.headers()['location']).toContain('success=user_deleted');
252+ });
253+
254+ test('admin cannot delete the admin account', async () => {
255+ const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
256+ form: { username: 'admin' },
257+ maxRedirects: 0,
258+ });
259+ expect(resp.status()).toBe(302);
260+ expect(resp.headers()['location']).toContain('error');
261+ });
262+
263+ test('settings page has no git identity section', async () => {
264+ const page = await adminCtx.newPage();
265+ try {
266+ await page.goto(`${BASE}/settings`);
267+ expect(await page.locator('text=Git Identity').count()).toBe(0);
268+ expect(await page.locator('[name=git_name]').count()).toBe(0);
269+ expect(await page.locator('[name=git_email]').count()).toBe(0);
270+ } finally { await page.close(); }
271+ });
272+
273+ test('git identity route no longer exists', async () => {
274+ const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
275+ form: { git_name: 'Test', git_email: 'test@example.com' },
276+ maxRedirects: 0,
277+ });
278+ expect(resp.status()).toBe(404);
279+ });
280+});
281+
282+// ─── Repository deletion ──────────────────────────────────────────────────────
283+
284+describe('repository deletion', () => {
285+ let adminCtx: BrowserContext;
286+
287+ beforeAll(async () => {
288+ adminCtx = await loggedInContext();
289+
290+ // Create a repo to delete
291+ const page = await adminCtx.newPage();
292+ try {
293+ await page.goto(`${BASE}/new`);
294+ await page.fill('[name=name]', 'deleteme-repo');
295+ await page.click('form[action="/new"] button[type=submit]');
296+ await page.waitForURL(`${BASE}/deleteme-repo`);
297+ } finally { await page.close(); }
298+ });
299+
300+ afterAll(async () => { await adminCtx.close(); });
301+
302+ test('admin can delete repository', async () => {
303+ const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
304+ maxRedirects: 0,
305+ });
306+ expect(resp.status()).toBe(302);
307+ expect(resp.headers()['location']).toBe('/');
308+ });
309+
310+ test('deleted repository returns 404', async () => {
311+ const page = await adminCtx.newPage();
312+ try {
313+ const resp = await page.request.get(`${BASE}/deleteme-repo`);
314+ expect(resp.status()).toBe(404);
315+ } finally { await page.close(); }
316+ });
317+
318+ test('deleted repository no longer appears in list', async () => {
319+ const page = await adminCtx.newPage();
320+ try {
321+ await page.goto(BASE);
322+ expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
323+ } finally { await page.close(); }
324+ });
325+
326+ test('non-admin cannot delete repository', async () => {
327+ const aliceCtx = await loggedInContext('alice', 'password123');
328+ const page = await aliceCtx.newPage();
329+ try {
330+ const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
331+ maxRedirects: 0,
332+ });
333+ expect(resp.status()).toBe(403);
334+ } finally {
335+ await page.close();
336+ await aliceCtx.close();
337+ }
338+ });
339+});
340+
341+// ─── 404 handling ─────────────────────────────────────────────────────────────
342+
343+describe('404 handling', () => {
344+ let adminCtx: BrowserContext;
345+
346+ beforeAll(async () => { adminCtx = await loggedInContext(); });
347+ afterAll(async () => { await adminCtx.close(); });
348+
349+ test('non-existent repository returns 404', async () => {
350+ const page = await adminCtx.newPage();
351+ try {
352+ const resp = await page.request.get(`${BASE}/no-such-repo`);
353+ expect(resp.status()).toBe(404);
354+ } finally { await page.close(); }
355+ });
356+
357+ test('non-existent issue returns 404', async () => {
358+ const page = await adminCtx.newPage();
359+ try {
360+ const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
361+ expect(resp.status()).toBe(404);
362+ } finally { await page.close(); }
363+ });
364+
365+ test('non-existent commit returns 404', async () => {
366+ const page = await adminCtx.newPage();
367+ try {
368+ const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
369+ expect(resp.status()).toBe(404);
370+ } finally { await page.close(); }
371+ });
372+
373+ test('non-existent file blob returns 404', async () => {
374+ const page = await adminCtx.newPage();
375+ try {
376+ const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
377+ expect(resp.status()).toBe(404);
378+ } finally { await page.close(); }
379+ });
380+
381+ test('non-existent patch returns 404', async () => {
382+ const page = await adminCtx.newPage();
383+ try {
384+ const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
385+ expect(resp.status()).toBe(404);
386+ } finally { await page.close(); }
387+ });
388+
389+ test('non-existent release returns 404', async () => {
390+ const page = await adminCtx.newPage();
391+ try {
392+ const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
393+ expect(resp.status()).toBe(404);
394+ } finally { await page.close(); }
395+ });
396+});
Atests/e2e.sorting.test.ts
@@ -0,0 +1,185 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ BASE,
6+ ADMIN_PASS,
7+ setupTestEnv,
8+ spawnServer,
9+ killServer,
10+ login,
11+} from './helpers.ts';
12+
13+let browser: Browser;
14+let server: Awaited<ReturnType<typeof spawnServer>>;
15+
16+beforeAll(async () => {
17+ await setupTestEnv();
18+ server = await spawnServer();
19+ browser = await chromium.launch();
20+});
21+
22+afterAll(async () => {
23+ await browser.close();
24+ await killServer(server);
25+});
26+
27+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
28+ const ctx = await browser.newContext();
29+ const page = await ctx.newPage();
30+ await login(page, username, password);
31+ await page.close();
32+ return ctx;
33+}
34+
35+// ─── Repo sorting and pinning ─────────────────────────────────────────────────
36+
37+describe('repo sorting and pinning', () => {
38+ let adminCtx: BrowserContext;
39+
40+ beforeAll(async () => {
41+ adminCtx = await loggedInContext();
42+ // Create two repos with predictable names: sort-aaa (created first/older),
43+ // sort-zzz (created second/newer). This lets us verify both name order and
44+ // creation-time order independently.
45+ // Also create my-repo which is navigated to in one test.
46+ const page = await adminCtx.newPage();
47+ try {
48+ await page.goto(`${BASE}/new`);
49+ await page.fill('[name=name]', 'my-repo');
50+ await page.click('form[action="/new"] button[type=submit]');
51+ await page.waitForURL(`${BASE}/my-repo`);
52+
53+ await page.goto(`${BASE}/new`);
54+ await page.fill('[name=name]', 'sort-aaa');
55+ await page.click('form[action="/new"] button[type=submit]');
56+ await page.waitForURL(`${BASE}/sort-aaa`);
57+
58+ await page.goto(`${BASE}/new`);
59+ await page.fill('[name=name]', 'sort-zzz');
60+ await page.click('form[action="/new"] button[type=submit]');
61+ await page.waitForURL(`${BASE}/sort-zzz`);
62+ } finally { await page.close(); }
63+ });
64+
65+ afterAll(async () => { await adminCtx.close(); });
66+
67+ test('sort dropdown is visible on repo list page', async () => {
68+ const page = await adminCtx.newPage();
69+ try {
70+ await page.goto(BASE);
71+ const options = await page.locator('.repo-sort-select option').allTextContents();
72+ expect(options.some(t => t.includes('Newest'))).toBe(true);
73+ expect(options.some(t => t.includes('Name'))).toBe(true);
74+ } finally { await page.close(); }
75+ });
76+
77+ test('newest option is selected by default', async () => {
78+ // Use a fresh context to ensure no repo_sort cookie is set.
79+ const ctx = await browser.newContext();
80+ const page = await ctx.newPage();
81+ try {
82+ await login(page, 'admin', ADMIN_PASS);
83+ await page.goto(BASE);
84+ expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
85+ } finally { await ctx.close(); }
86+ });
87+
88+ test('Go button is hidden with JS and works without JS', async () => {
89+ const ctx = await browser.newContext({ javaScriptEnabled: false });
90+ const page = await ctx.newPage();
91+ try {
92+ await login(page, 'admin', ADMIN_PASS);
93+ await page.goto(BASE);
94+ // Go button visible without JS
95+ expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
96+ // Select name sort and submit via Go button
97+ await page.locator('.repo-sort-select').selectOption('name');
98+ await page.locator('form[action="/sort"] button[type=submit]').click();
99+ await page.waitForURL(BASE + '/');
100+ expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
101+ } finally { await ctx.close(); }
102+ });
103+
104+ test('selecting name sort sets cookie and persists on next visit', async () => {
105+ const page = await adminCtx.newPage();
106+ try {
107+ await page.goto(BASE);
108+ await Promise.all([
109+ page.waitForURL(BASE + '/'),
110+ page.locator('.repo-sort-select').selectOption('name'),
111+ ]);
112+ expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
113+ // Navigate away and back to confirm cookie persists
114+ await page.goto(`${BASE}/my-repo`);
115+ await page.goto(BASE);
116+ expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
117+ } finally {
118+ // Reset cookie so subsequent tests start from the default sort.
119+ await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
120+ await page.close();
121+ }
122+ });
123+
124+ test('default sort shows newer repo before older repo', async () => {
125+ const page = await adminCtx.newPage();
126+ try {
127+ await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
128+ await page.goto(`${BASE}/?q=sort-`);
129+ const names = await page.locator('.repo-name').allTextContents();
130+ expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
131+ } finally { await page.close(); }
132+ });
133+
134+ test('name sort shows repos in alphabetical order', async () => {
135+ const page = await adminCtx.newPage();
136+ try {
137+ await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
138+ await page.goto(`${BASE}/?q=sort-`);
139+ const names = await page.locator('.repo-name').allTextContents();
140+ expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
141+ } finally {
142+ await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
143+ await page.close();
144+ }
145+ });
146+
147+ test('pinning a repo shows pinned badge on list page', async () => {
148+ const page = await adminCtx.newPage();
149+ try {
150+ await page.goto(`${BASE}/sort-aaa/settings`);
151+ await page.check('[name=is_pinned]');
152+ await page.click('form[action$="/settings"] button[type=submit]');
153+ expect(await page.locator('.form-success').isVisible()).toBe(true);
154+
155+ await page.goto(BASE);
156+ const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
157+ expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
158+ } finally { await page.close(); }
159+ });
160+
161+ test('pinned repo appears before unpinned repos regardless of creation order', async () => {
162+ const page = await adminCtx.newPage();
163+ try {
164+ // sort-aaa is pinned; default (newest) sort would normally show sort-zzz
165+ // first since it's newer — but pinned repos float to the top.
166+ await page.goto(`${BASE}/?q=sort-`);
167+ const names = await page.locator('.repo-name').allTextContents();
168+ expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
169+ } finally { await page.close(); }
170+ });
171+
172+ test('unpinning a repo removes the pinned badge', async () => {
173+ const page = await adminCtx.newPage();
174+ try {
175+ await page.goto(`${BASE}/sort-aaa/settings`);
176+ await page.uncheck('[name=is_pinned]');
177+ await page.click('form[action$="/settings"] button[type=submit]');
178+ expect(await page.locator('.form-success').isVisible()).toBe(true);
179+
180+ await page.goto(BASE);
181+ const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
182+ expect(await card.locator('.badge-pinned').count()).toBe(0);
183+ } finally { await page.close(); }
184+ });
185+});
Dtests/e2e.test.ts
-3458
@@ -1,3458 +0,0 @@
1-import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2-import { chromium } from 'playwright';
3-import type { Browser, BrowserContext } from 'playwright';
4-import { $ } from 'bun';
5-import { existsSync, readFileSync } from 'node:fs';
6-import {
7- BASE,
8- DATA_DIR,
9- ADMIN_PASS,
10- setupTestEnv,
11- spawnServer,
12- waitForServer,
13- login,
14- logout,
15- seedRepo,
16- seedBranch,
17- seedSubdir,
18- writeTempFile,
19- getHeadCommit,
20- PORT,
21-} from './helpers.ts';
22-
23-// ─── Global setup ────────────────────────────────────────────────────────────
24-
25-let browser: Browser;
26-let server: ReturnType<typeof spawnServer>;
27-
28-beforeAll(async () => {
29- await setupTestEnv();
30- server = spawnServer();
31- await waitForServer();
32- browser = await chromium.launch();
33-});
34-
35-afterAll(async () => {
36- await browser.close();
37- server.kill();
38-});
39-
40-// Helper: create a context already logged in as a given user.
41-async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
42- const ctx = await browser.newContext();
43- const page = await ctx.newPage();
44- await login(page, username, password);
45- await page.close();
46- return ctx;
47-}
48-
49-// Helper: create N issues in a repo using the server API (no browser rendering)
50-async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
51- for (let i = 1; i <= count; i++) {
52- await ctx.request.fetch(`${BASE}/${repo}/issues`, {
53- method: 'POST',
54- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
55- data: `title=Issue+number+${i}&body=`,
56- maxRedirects: 0,
57- }).catch(() => {}); // 302 redirect throws; that's fine
58- }
59-}
60-
61-// Helper: create N patches in a repo using the server API
62-async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
63- const VALID_PATCH = [
64- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
65- 'From: Test User <test@example.com>',
66- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
67- 'Subject: [PATCH] Add f.txt',
68- '',
69- '---',
70- 'diff --git a/f.txt b/f.txt',
71- 'new file mode 100644',
72- 'index 0000000..9daeafb',
73- '--- /dev/null',
74- '+++ b/f.txt',
75- '@@ -0,0 +1 @@',
76- '+x',
77- '',
78- ].join('\n');
79-
80- writeTempFile('/tmp/bulk.patch', VALID_PATCH);
81- for (let i = 1; i <= count; i++) {
82- const page = await ctx.newPage();
83- try {
84- await page.goto(`${BASE}/${repo}/patches/new`);
85- await page.fill('[name=title]', `Patch number ${i}`);
86- await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch');
87- await page.click('form[action$="/patches"] button[type=submit]');
88- await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`));
89- } finally { await page.close(); }
90- }
91-}
92-
93-// ─── Auth ─────────────────────────────────────────────────────────────────────
94-
95-describe('auth', () => {
96- test('homepage loads', async () => {
97- const ctx = await browser.newContext();
98- const page = await ctx.newPage();
99- try {
100- await page.goto(BASE);
101- expect(await page.title()).toContain('Hearthforge');
102- } finally { await ctx.close(); }
103- });
104-
105- test('wrong password shows error', async () => {
106- const ctx = await browser.newContext();
107- const page = await ctx.newPage();
108- try {
109- await page.goto(`${BASE}/login`);
110- await page.fill('[name=username]', 'admin');
111- await page.fill('[name=password]', 'wrongpassword');
112- await page.click('button[type=submit]');
113- expect(await page.locator('.form-error').textContent()).toContain('Invalid');
114- } finally { await ctx.close(); }
115- });
116-
117- test('correct credentials redirect to homepage', async () => {
118- const ctx = await browser.newContext();
119- const page = await ctx.newPage();
120- try {
121- await login(page);
122- expect(page.url()).toBe(BASE + '/');
123- expect(await page.locator('.nav-user').isVisible()).toBe(true);
124- } finally { await ctx.close(); }
125- });
126-
127- test('register new user', async () => {
128- const ctx = await browser.newContext();
129- const page = await ctx.newPage();
130- try {
131- await page.goto(`${BASE}/register`);
132- await page.fill('[name=username]', 'alice');
133- await page.fill('[name=password]', 'password123');
134- await page.fill('[name=password2]', 'password123');
135- await page.click('button[type=submit]');
136- await page.waitForURL(BASE + '/');
137- expect(await page.locator('.nav-user').textContent()).toBe('alice');
138- } finally { await ctx.close(); }
139- });
140-
141- test('register with mismatched passwords shows error', async () => {
142- const ctx = await browser.newContext();
143- const page = await ctx.newPage();
144- try {
145- await page.goto(`${BASE}/register`);
146- await page.fill('[name=username]', 'bob');
147- await page.fill('[name=password]', 'password123');
148- await page.fill('[name=password2]', 'different456');
149- await page.click('button[type=submit]');
150- expect(await page.locator('.form-error').textContent()).toContain('match');
151- } finally { await ctx.close(); }
152- });
153-
154- test('register with duplicate username shows error', async () => {
155- const ctx = await browser.newContext();
156- const page = await ctx.newPage();
157- try {
158- await page.goto(`${BASE}/register`);
159- await page.fill('[name=username]', 'alice'); // already registered above
160- await page.fill('[name=password]', 'password123');
161- await page.fill('[name=password2]', 'password123');
162- await page.click('button[type=submit]');
163- expect(await page.locator('.form-error').textContent()).toContain('taken');
164- } finally { await ctx.close(); }
165- });
166-
167- test('logout clears session', async () => {
168- const ctx = await browser.newContext();
169- const page = await ctx.newPage();
170- try {
171- await login(page);
172- await logout(page);
173- expect(await page.locator('.nav-user').count()).toBe(0);
174- expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
175- } finally { await ctx.close(); }
176- });
177-});
178-
179-// ─── Repos ────────────────────────────────────────────────────────────────────
180-
181-describe('repos', () => {
182- // Shared admin context — cookies persist across tests in this block.
183- let adminCtx: BrowserContext;
184- // Alice's context, created after alice is registered in auth tests.
185- let aliceCtx: BrowserContext;
186- // Set after 'commit log' test; used by all commit-detail tests below.
187- let commitUrl: string;
188-
189- beforeAll(async () => {
190- adminCtx = await loggedInContext();
191- aliceCtx = await loggedInContext('alice', 'password123');
192- });
193-
194- afterAll(async () => {
195- await adminCtx.close();
196- await aliceCtx.close();
197- });
198-
199- test('non-admin gets 403 on /new', async () => {
200- const page = await aliceCtx.newPage();
201- try {
202- const resp = await page.request.get(`${BASE}/new`);
203- expect(resp.status()).toBe(403);
204- } finally { await page.close(); }
205- });
206-
207- test('create repository', async () => {
208- const page = await adminCtx.newPage();
209- try {
210- await page.goto(`${BASE}/new`);
211- await page.fill('[name=name]', 'my-repo');
212- await page.fill('[name=description]', 'A test repo');
213- await page.click('form[action="/new"] button[type=submit]');
214- await page.waitForURL(`${BASE}/my-repo`);
215- expect(await page.locator('.empty-state').isVisible()).toBe(true);
216- } finally { await page.close(); }
217- });
218-
219- test('repository appears in list', async () => {
220- const page = await adminCtx.newPage();
221- try {
222- await page.goto(BASE);
223- expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
224- } finally { await page.close(); }
225- });
226-
227- test('search finds matching repo', async () => {
228- const page = await adminCtx.newPage();
229- try {
230- await page.goto(BASE);
231- await page.fill('[name=q]', 'my-repo');
232- await page.click('.search-form button[type=submit]');
233- expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
234- } finally { await page.close(); }
235- });
236-
237- test('search returns empty for unknown term', async () => {
238- const page = await adminCtx.newPage();
239- try {
240- await page.goto(BASE);
241- await page.fill('[name=q]', 'zzz-nothing-here');
242- await page.click('.search-form button[type=submit]');
243- expect(await page.locator('.empty-state').isVisible()).toBe(true);
244- } finally { await page.close(); }
245- });
246-
247- test('browse file tree after seeding content', async () => {
248- await seedRepo('my-repo');
249- const page = await adminCtx.newPage();
250- try {
251- await page.goto(`${BASE}/my-repo/tree/main`);
252- const files = await page.locator('.file-name a').allTextContents();
253- expect(files).toContain('README.md');
254- expect(files).toContain('index.js');
255- } finally { await page.close(); }
256- });
257-
258- test('view file blob with syntax highlighting', async () => {
259- const page = await adminCtx.newPage();
260- try {
261- await page.goto(`${BASE}/my-repo/blob/main/index.js`);
262- expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
263- expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
264- } finally { await page.close(); }
265- });
266-
267- test('raw file download responds 200', async () => {
268- const page = await adminCtx.newPage();
269- try {
270- const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
271- expect(resp.status()).toBe(200);
272- expect(resp.headers()['content-disposition']).toContain('README.md');
273- } finally { await page.close(); }
274- });
275-
276- test('commit log shows initial commit', async () => {
277- const page = await adminCtx.newPage();
278- try {
279- await page.goto(`${BASE}/my-repo/commits/main`);
280- const subjects = await page.locator('.commit-subject').allTextContents();
281- expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
282- // Navigate to the commit page and capture the URL for subsequent tests
283- await page.locator('.commit-hash').first().click();
284- await page.waitForURL(/\/my-repo\/commit\//);
285- commitUrl = page.url();
286- } finally { await page.close(); }
287- });
288-
289- test('commit detail shows metadata card', async () => {
290- const page = await adminCtx.newPage();
291- try {
292- await page.goto(commitUrl);
293- expect(await page.locator('.commit-card').isVisible()).toBe(true);
294- expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
295- // Author, date and SHA rows are all present
296- const metaText = await page.locator('.commit-card-meta').textContent();
297- expect(metaText).toContain('Test'); // author name set by seedRepo
298- expect(metaText).toContain('Author'); // label (CSS uppercases visually)
299- expect(metaText).toContain('Date');
300- expect(metaText).toContain('Commit');
301- } finally { await page.close(); }
302- });
303-
304- test('commit detail full SHA is shown', async () => {
305- const page = await adminCtx.newPage();
306- try {
307- await page.goto(commitUrl);
308- const sha = commitUrl.split('/commit/')[1] ?? null;
309- expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
310- } finally { await page.close(); }
311- });
312-
313- test('commit detail shows file nav sidebar', async () => {
314- const page = await adminCtx.newPage();
315- try {
316- await page.goto(commitUrl);
317- expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
318- const navItems = await page.locator('.file-nav-item').allTextContents();
319- // seedRepo adds README.md and index.js
320- expect(navItems.some(t => t.includes('README.md'))).toBe(true);
321- expect(navItems.some(t => t.includes('index.js'))).toBe(true);
322- } finally { await page.close(); }
323- });
324-
325- test('commit detail file nav items are anchor links to diff sections', async () => {
326- const page = await adminCtx.newPage();
327- try {
328- await page.goto(commitUrl);
329- const hrefs = await page.locator('.file-nav-item').evaluateAll(
330- els => els.map(el => el.getAttribute('href') ?? ''),
331- );
332- expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
333- } finally { await page.close(); }
334- });
335-
336- test('commit detail shows diff table with added lines', async () => {
337- const page = await adminCtx.newPage();
338- try {
339- await page.goto(commitUrl);
340- // Initial commit only adds lines
341- expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
342- expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
343- expect(await page.locator('.diff-row-del').count()).toBe(0);
344- } finally { await page.close(); }
345- });
346-
347- test('commit detail diff table has line numbers', async () => {
348- const page = await adminCtx.newPage();
349- try {
350- await page.goto(commitUrl);
351- // New-side line numbers (column 2) on add rows start at 1
352- const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
353- expect(firstNewLn?.trim()).toBe('1');
354- } finally { await page.close(); }
355- });
356-
357- test('commit detail shows added stats on file header', async () => {
358- const page = await adminCtx.newPage();
359- try {
360- await page.goto(commitUrl);
361- const addStats = await page.locator('.diff-stat-add').allTextContents();
362- expect(addStats.length).toBeGreaterThan(0);
363- expect(addStats.every(s => s.startsWith('+'))).toBe(true);
364- } finally { await page.close(); }
365- });
366-
367- test('commit detail view-at-sha button links to blob at that commit', async () => {
368- const page = await adminCtx.newPage();
369- try {
370- await page.goto(commitUrl);
371- const sha = commitUrl.split('/commit/')[1];
372- const btn = page.locator('.btn-xs').first();
373- const href = await btn.getAttribute('href');
374- expect(href).toContain(`/blob/${sha}/`);
375- } finally { await page.close(); }
376- });
377-
378- test('commit detail view-at-branch button links to blob at default branch', async () => {
379- const page = await adminCtx.newPage();
380- try {
381- await page.goto(commitUrl);
382- const btns = await page.locator('.btn-xs').allTextContents();
383- expect(btns.some(t => t.includes('@ main'))).toBe(true);
384- const branchBtns = await page.locator('.btn-xs').evaluateAll(
385- els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
386- );
387- expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
388- } finally { await page.close(); }
389- });
390-
391- test('commit detail file diff can be collapsed', async () => {
392- const page = await adminCtx.newPage();
393- try {
394- await page.goto(commitUrl);
395- // File body is visible when details is open
396- const diffFile = page.locator('.diff-file').first();
397- expect(await diffFile.getAttribute('open')).not.toBeNull();
398- // Click the summary to collapse
399- await diffFile.locator('.diff-file-header').click();
400- expect(await diffFile.getAttribute('open')).toBeNull();
401- } finally { await page.close(); }
402- });
403-
404- test('commit detail file nav sidebar can be collapsed', async () => {
405- const page = await adminCtx.newPage();
406- try {
407- await page.goto(commitUrl);
408- const nav = page.locator('.file-nav-details');
409- expect(await nav.getAttribute('open')).not.toBeNull();
410- await nav.locator('.file-nav-toggle').click();
411- expect(await nav.getAttribute('open')).toBeNull();
412- } finally { await page.close(); }
413- });
414-
415- test('readme renders on repo home', async () => {
416- const page = await adminCtx.newPage();
417- try {
418- await page.goto(`${BASE}/my-repo`);
419- expect(await page.locator('.readme-header').isVisible()).toBe(true);
420- expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
421- } finally { await page.close(); }
422- });
423-
424- test('private repo hidden from other users', async () => {
425- // Make private
426- const adminPage = await adminCtx.newPage();
427- try {
428- await adminPage.goto(`${BASE}/my-repo/settings`);
429- await adminPage.check('[name=is_private]');
430- await adminPage.click('form[action$="/settings"] button[type=submit]');
431- expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
432- } finally { await adminPage.close(); }
433-
434- // Alice should get 404
435- const alicePage = await aliceCtx.newPage();
436- try {
437- const resp = await alicePage.request.get(`${BASE}/my-repo`);
438- expect(resp.status()).toBe(404);
439- await alicePage.goto(BASE);
440- expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
441- } finally { await alicePage.close(); }
442-
443- // Restore to public
444- const adminPage2 = await adminCtx.newPage();
445- try {
446- await adminPage2.goto(`${BASE}/my-repo/settings`);
447- await adminPage2.uncheck('[name=is_private]');
448- await adminPage2.click('form[action$="/settings"] button[type=submit]');
449- } finally { await adminPage2.close(); }
450- });
451-
452- test('settings tab visible for admin, hidden for others', async () => {
453- const adminPage = await adminCtx.newPage();
454- try {
455- await adminPage.goto(`${BASE}/my-repo`);
456- expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
457- } finally { await adminPage.close(); }
458-
459- const alicePage = await aliceCtx.newPage();
460- try {
461- await alicePage.goto(`${BASE}/my-repo`);
462- expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
463- } finally { await alicePage.close(); }
464- });
465-
466- test('create repository with invalid name shows error', async () => {
467- const resp = await adminCtx.request.post(`${BASE}/new`, {
468- form: { name: 'has spaces!', description: '', default_branch: 'main' },
469- maxRedirects: 0,
470- });
471- expect(resp.status()).toBe(200);
472- expect(await resp.text()).toContain('Invalid repository name');
473- });
474-});
475-
476-// ─── Issues ───────────────────────────────────────────────────────────────────
477-
478-describe('issues', () => {
479- let adminCtx: BrowserContext;
480- let issueUrl: string;
481- let completedIssueUrl: string;
482-
483- beforeAll(async () => {
484- adminCtx = await loggedInContext();
485- });
486-
487- afterAll(async () => { await adminCtx.close(); });
488-
489- test('create issue', async () => {
490- const page = await adminCtx.newPage();
491- try {
492- await page.goto(`${BASE}/my-repo/issues/new`);
493- await page.fill('[name=title]', 'First issue');
494- await page.fill('[name=body]', 'Body with **markdown**.');
495- await page.click('form[action$="/issues"] button[type=submit]');
496- await page.waitForURL(/\/my-repo\/issues\/\d+/);
497- issueUrl = page.url();
498- expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
499- } finally { await page.close(); }
500- });
501-
502- test('issue body renders markdown', async () => {
503- const page = await adminCtx.newPage();
504- try {
505- await page.goto(issueUrl);
506- expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
507- } finally { await page.close(); }
508- });
509-
510- test('issue appears in open list', async () => {
511- const page = await adminCtx.newPage();
512- try {
513- await page.goto(`${BASE}/my-repo/issues`);
514- const titles = await page.locator('.issue-title').allTextContents();
515- expect(titles.some(t => t.includes('First issue'))).toBe(true);
516- } finally { await page.close(); }
517- });
518-
519- test('unauthenticated user is redirected to login from new issue form', async () => {
520- const ctx = await browser.newContext();
521- const page = await ctx.newPage();
522- try {
523- await page.goto(`${BASE}/my-repo/issues/new`);
524- expect(page.url()).toContain('/login');
525- } finally { await ctx.close(); }
526- });
527-
528- test('add comment', async () => {
529- const page = await adminCtx.newPage();
530- try {
531- await page.goto(issueUrl);
532- const beforeCount = await page.locator('.timeline-item').count();
533- await page.fill('textarea[name=body]', 'A follow-up comment.');
534- await page.click('form[action*="/comments"] button[type=submit]');
535- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
536- expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
537- } finally { await page.close(); }
538- });
539-
540- test('react to issue', async () => {
541- const page = await adminCtx.newPage();
542- try {
543- await page.goto(issueUrl);
544- await page.locator('.reaction-picker').first().click();
545- await page.locator('.reaction-picker-btn').first().click();
546- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
547- expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
548- } finally { await page.close(); }
549- });
550-
551- test('close issue changes status badge', async () => {
552- const page = await adminCtx.newPage();
553- try {
554- await page.goto(issueUrl);
555- await page.click('form[action*="/close"] button');
556- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
557- expect(await page.locator('.issue-badge').textContent()).toBe('closed');
558- } finally { await page.close(); }
559- });
560-
561- test('closed issue appears in closed list', async () => {
562- const page = await adminCtx.newPage();
563- try {
564- await page.goto(`${BASE}/my-repo/issues?status=closed`);
565- const titles = await page.locator('.issue-title').allTextContents();
566- expect(titles.some(t => t.includes('First issue'))).toBe(true);
567- } finally { await page.close(); }
568- });
569-
570- test('reopen issue', async () => {
571- const page = await adminCtx.newPage();
572- try {
573- await page.goto(issueUrl);
574- await page.click('.issue-detail-meta-actions button');
575- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
576- expect(await page.locator('.issue-badge').textContent()).toBe('open');
577- } finally { await page.close(); }
578- });
579-
580- test('completed button marks issue as completed', async () => {
581- const page = await adminCtx.newPage();
582- try {
583- await page.goto(`${BASE}/my-repo/issues/new`);
584- await page.fill('[name=title]', 'To be completed');
585- await page.click('form[action$="/issues"] button[type=submit]');
586- await page.waitForURL(/\/my-repo\/issues\/\d+/);
587- completedIssueUrl = page.url();
588- await page.click('form[action*="/complete"] button');
589- await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
590- expect(await page.locator('.issue-badge').textContent()).toBe('completed');
591- } finally { await page.close(); }
592- });
593-
594- test('completed issue appears in completed list', async () => {
595- const page = await adminCtx.newPage();
596- try {
597- await page.goto(`${BASE}/my-repo/issues?status=completed`);
598- const titles = await page.locator('.issue-title').allTextContents();
599- expect(titles.some(t => t.includes('To be completed'))).toBe(true);
600- } finally { await page.close(); }
601- });
602-
603- test('non-admin cannot complete or close issue', async () => {
604- const issueNum = issueUrl.split('/issues/')[1];
605- const ctx = await browser.newContext();
606- try {
607- const completeResp = await ctx.request.post(
608- `${BASE}/my-repo/issues/${issueNum}/complete`,
609- { maxRedirects: 0 },
610- );
611- expect(completeResp.status()).toBe(302);
612- expect(completeResp.headers()['location']).toContain('/login');
613- } finally { await ctx.close(); }
614- });
615-
616- test('reacting with same emoji toggles it off', async () => {
617- const page = await adminCtx.newPage();
618- try {
619- await page.goto(issueUrl);
620- // Reaction was added by the earlier 'react to issue' test
621- expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
622- await page.locator('.reaction-btn').first().click();
623- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
624- expect(await page.locator('.reaction-btn').count()).toBe(0);
625- } finally { await page.close(); }
626- });
627-
628- test('react to issue comment', async () => {
629- const page = await adminCtx.newPage();
630- try {
631- await page.goto(issueUrl);
632- const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
633- .filter({ hasText: 'A follow-up comment.' });
634- await commentItem.locator('.reaction-add-btn').click();
635- await commentItem.locator('.reaction-picker-btn').first().click();
636- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
637- expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
638- } finally { await page.close(); }
639- });
640-});
641-
642-// ─── Patches ──────────────────────────────────────────────────────────────────
643-
644-describe('patches', () => {
645- let adminCtx: BrowserContext;
646- let cleanPatchUrl: string;
647- let conflictPatchUrl: string;
648- let closePatchUrl: string;
649-
650- // Adds a new file — applies cleanly to my-repo
651- const CLEAN_PATCH = [
652- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
653- 'From: Test User <test@example.com>',
654- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
655- 'Subject: [PATCH] Add patch-test.txt',
656- '',
657- '---',
658- 'diff --git a/patch-test.txt b/patch-test.txt',
659- 'new file mode 100644',
660- 'index 0000000..9daeafb',
661- '--- /dev/null',
662- '+++ b/patch-test.txt',
663- '@@ -0,0 +1 @@',
664- '+patch test content',
665- '',
666- ].join('\n');
667-
668- // References non-existent lines in README.md — always conflicts
669- const CONFLICT_PATCH = [
670- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
671- 'From: Test User <test@example.com>',
672- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
673- 'Subject: [PATCH] Modify README',
674- '',
675- '---',
676- 'diff --git a/README.md b/README.md',
677- 'index abc1234..def5678 100644',
678- '--- a/README.md',
679- '+++ b/README.md',
680- '@@ -50,3 +50,3 @@',
681- ' nonexistent context line',
682- '-nonexistent old line',
683- '+nonexistent new line',
684- '',
685- ].join('\n');
686-
687- // Adds another new file — for testing close flow
688- const CLOSE_PATCH = [
689- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
690- 'From: Test User <test@example.com>',
691- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
692- 'Subject: [PATCH] Add patch-close.txt',
693- '',
694- '---',
695- 'diff --git a/patch-close.txt b/patch-close.txt',
696- 'new file mode 100644',
697- 'index 0000000..9daeafb',
698- '--- /dev/null',
699- '+++ b/patch-close.txt',
700- '@@ -0,0 +1 @@',
701- '+close test',
702- '',
703- ].join('\n');
704-
705- beforeAll(async () => {
706- adminCtx = await loggedInContext();
707- });
708-
709- afterAll(async () => { await adminCtx.close(); });
710-
711- test('reject file without patch markers', async () => {
712- writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
713- const page = await adminCtx.newPage();
714- try {
715- await page.goto(`${BASE}/my-repo/patches/new`);
716- await page.fill('[name=title]', 'Bad patch');
717- await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
718- await page.click('form[action$="/patches"] button[type=submit]');
719- expect(await page.locator('.form-error').textContent()).toContain('valid patch');
720- } finally { await page.close(); }
721- });
722-
723- test('reject patch missing Subject header', async () => {
724- writeTempFile('/tmp/no-subject.patch', [
725- 'From: Test User <test@example.com>',
726- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
727- '',
728- '---',
729- 'diff --git a/f.txt b/f.txt',
730- 'new file mode 100644',
731- '--- /dev/null',
732- '+++ b/f.txt',
733- '@@ -0,0 +1 @@',
734- '+x',
735- '',
736- ].join('\n'));
737- const page = await adminCtx.newPage();
738- try {
739- await page.goto(`${BASE}/my-repo/patches/new`);
740- await page.fill('[name=title]', 'No subject');
741- await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
742- await page.click('form[action$="/patches"] button[type=submit]');
743- expect(await page.locator('.form-error').textContent()).toContain('Subject');
744- } finally { await page.close(); }
745- });
746-
747- test('reject patch missing From header', async () => {
748- writeTempFile('/tmp/no-from.patch', [
749- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
750- 'Subject: [PATCH] Add f.txt',
751- '',
752- '---',
753- 'diff --git a/f.txt b/f.txt',
754- 'new file mode 100644',
755- '--- /dev/null',
756- '+++ b/f.txt',
757- '@@ -0,0 +1 @@',
758- '+x',
759- '',
760- ].join('\n'));
761- const page = await adminCtx.newPage();
762- try {
763- await page.goto(`${BASE}/my-repo/patches/new`);
764- await page.fill('[name=title]', 'No from');
765- await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
766- await page.click('form[action$="/patches"] button[type=submit]');
767- expect(await page.locator('.form-error').textContent()).toContain('From');
768- } finally { await page.close(); }
769- });
770-
771- test('reject patch missing Date header', async () => {
772- writeTempFile('/tmp/no-date.patch', [
773- 'From: Test User <test@example.com>',
774- 'Subject: [PATCH] Add f.txt',
775- '',
776- '---',
777- 'diff --git a/f.txt b/f.txt',
778- 'new file mode 100644',
779- '--- /dev/null',
780- '+++ b/f.txt',
781- '@@ -0,0 +1 @@',
782- '+x',
783- '',
784- ].join('\n'));
785- const page = await adminCtx.newPage();
786- try {
787- await page.goto(`${BASE}/my-repo/patches/new`);
788- await page.fill('[name=title]', 'No date');
789- await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
790- await page.click('form[action$="/patches"] button[type=submit]');
791- expect(await page.locator('.form-error').textContent()).toContain('Date');
792- } finally { await page.close(); }
793- });
794-
795- test('upload clean patch', async () => {
796- writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
797- const page = await adminCtx.newPage();
798- try {
799- await page.goto(`${BASE}/my-repo/patches/new`);
800- await page.fill('[name=title]', 'Add patch-test.txt');
801- await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
802- await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
803- await page.click('form[action$="/patches"] button[type=submit]');
804- await page.waitForURL(/\/my-repo\/patches\/\d+/);
805- cleanPatchUrl = page.url();
806- expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
807- } finally { await page.close(); }
808- });
809-
810- test('changes tab shows commit metadata card', async () => {
811- const page = await adminCtx.newPage();
812- try {
813- await page.goto(cleanPatchUrl + '?tab=changes');
814- expect(await page.locator('.commit-card').isVisible()).toBe(true);
815- expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
816- expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
817- expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
818- expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
819- } finally { await page.close(); }
820- });
821-
822- test('patch description renders markdown', async () => {
823- const page = await adminCtx.newPage();
824- try {
825- await page.goto(cleanPatchUrl);
826- expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
827- } finally { await page.close(); }
828- });
829-
830- test('clean patch shows apply-clean status immediately', async () => {
831- const page = await adminCtx.newPage();
832- try {
833- await page.goto(cleanPatchUrl);
834- expect(await page.locator('.apply-result').isVisible()).toBe(true);
835- expect(await page.locator('.apply-clean').isVisible()).toBe(true);
836- } finally { await page.close(); }
837- });
838-
839- test('merge button appears for clean patch', async () => {
840- const page = await adminCtx.newPage();
841- try {
842- await page.goto(cleanPatchUrl);
843- expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
844- } finally { await page.close(); }
845- });
846-
847- test('patch diff is displayed with highlighted table', async () => {
848- const page = await adminCtx.newPage();
849- try {
850- await page.goto(cleanPatchUrl + '?tab=changes');
851- expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
852- expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
853- } finally { await page.close(); }
854- });
855-
856- test('patch appears in open list', async () => {
857- const page = await adminCtx.newPage();
858- try {
859- await page.goto(`${BASE}/my-repo/patches`);
860- const titles = await page.locator('.issue-title').allTextContents();
861- expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
862- } finally { await page.close(); }
863- });
864-
865- test('unauthenticated user is redirected to login from patch upload', async () => {
866- const ctx = await browser.newContext();
867- const page = await ctx.newPage();
868- try {
869- await page.goto(`${BASE}/my-repo/patches/new`);
870- expect(page.url()).toContain('/login');
871- } finally { await ctx.close(); }
872- });
873-
874- test('upload conflict patch', async () => {
875- writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
876- const page = await adminCtx.newPage();
877- try {
878- await page.goto(`${BASE}/my-repo/patches/new`);
879- await page.fill('[name=title]', 'Conflict patch');
880- await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
881- await page.click('form[action$="/patches"] button[type=submit]');
882- await page.waitForURL(/\/my-repo\/patches\/\d+/);
883- conflictPatchUrl = page.url();
884- } finally { await page.close(); }
885- });
886-
887- test('conflict patch shows apply-conflict status', async () => {
888- const page = await adminCtx.newPage();
889- try {
890- await page.goto(conflictPatchUrl);
891- expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
892- } finally { await page.close(); }
893- });
894-
895- test('merge button absent for conflict patch', async () => {
896- const page = await adminCtx.newPage();
897- try {
898- await page.goto(conflictPatchUrl);
899- expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
900- } finally { await page.close(); }
901- });
902-
903- test('merge clean patch changes status to merged', async () => {
904- const page = await adminCtx.newPage();
905- try {
906- await page.goto(cleanPatchUrl);
907- await page.click('form[action*="/merge"] button');
908- await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
909- expect(await page.locator('.patch-badge').textContent()).toBe('merged');
910- } finally { await page.close(); }
911- });
912-
913- test('merge uses patch From header as git author', async () => {
914- const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
915- const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
916- const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim();
917- const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim();
918- expect(authorName).toBe('Test User');
919- expect(authorEmail).toBe('test@example.com');
920- expect(subject).toBe('Add patch-test.txt');
921- });
922-
923- test('merged patch appears in merged list', async () => {
924- const page = await adminCtx.newPage();
925- try {
926- await page.goto(`${BASE}/my-repo/patches?status=merged`);
927- const titles = await page.locator('.issue-title').allTextContents();
928- expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
929- } finally { await page.close(); }
930- });
931-
932- test('upload and close a patch', async () => {
933- writeTempFile('/tmp/close.patch', CLOSE_PATCH);
934- const page = await adminCtx.newPage();
935- try {
936- await page.goto(`${BASE}/my-repo/patches/new`);
937- await page.fill('[name=title]', 'Close me');
938- await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
939- await page.click('form[action$="/patches"] button[type=submit]');
940- await page.waitForURL(/\/my-repo\/patches\/\d+/);
941- closePatchUrl = page.url();
942- await page.click('form[action*="/close"] button');
943- await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
944- expect(await page.locator('.patch-badge').textContent()).toBe('closed');
945- } finally { await page.close(); }
946- });
947-
948- test('closed patch appears in closed list', async () => {
949- const page = await adminCtx.newPage();
950- try {
951- await page.goto(`${BASE}/my-repo/patches?status=closed`);
952- const titles = await page.locator('.issue-title').allTextContents();
953- expect(titles.some(t => t.includes('Close me'))).toBe(true);
954- } finally { await page.close(); }
955- });
956-
957- test('closed patch can be reopened', async () => {
958- const page = await adminCtx.newPage();
959- try {
960- await page.goto(closePatchUrl);
961- expect(await page.locator('.patch-badge').textContent()).toBe('closed');
962- await page.click('form[action*="/close"] button');
963- await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
964- expect(await page.locator('.patch-badge').textContent()).toBe('open');
965- } finally { await page.close(); }
966- });
967-
968- test('patch title and description can be edited', async () => {
969- const page = await adminCtx.newPage();
970- try {
971- await page.goto(conflictPatchUrl);
972- // Edit title via title form
973- await page.click('details.title-edit-details summary');
974- await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
975- await page.click('.title-edit-form-area [type=submit]');
976- await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
977- expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
978- // Edit description via inline form
979- await page.click('.timeline-author .inline-edit-details summary');
980- await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
981- await page.click('.inline-edit-form-area [type=submit]');
982- await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
983- } finally { await page.close(); }
984- });
985-
986- test('patch comment: add and edit', async () => {
987- const page = await adminCtx.newPage();
988- try {
989- await page.goto(conflictPatchUrl);
990- await page.fill('[name=body]', 'My patch comment');
991- await page.click('form[action$="/comments"] button[type=submit]');
992- await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
993- expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
994-
995- // Edit the comment — scope to the timeline-item containing the comment text
996- const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
997- await commentItem.locator('.inline-edit-details summary').click();
998- await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
999- await commentItem.locator('.inline-edit-form-area [type=submit]').click();
1000- await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
1001- expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
1002- } finally { await page.close(); }
1003- });
1004-
1005- test('patch reaction on description', async () => {
1006- const page = await adminCtx.newPage();
1007- try {
1008- await page.goto(conflictPatchUrl);
1009- // Open reaction picker on the first timeline-item (description)
1010- await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
1011- await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
1012- await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
1013- expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
1014- } finally { await page.close(); }
1015- });
1016-
1017- test('admin can delete a patch', async () => {
1018- const page = await adminCtx.newPage();
1019- try {
1020- const patchNum = conflictPatchUrl.split('/patches/')[1];
1021- const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
1022- maxRedirects: 0,
1023- });
1024- expect(resp.status()).toBe(302);
1025- expect(resp.headers()['location']).toContain('/patches');
1026- // Patch should no longer be accessible
1027- const checkResp = await page.request.get(conflictPatchUrl);
1028- expect(checkResp.status()).toBe(404);
1029- } finally { await page.close(); }
1030- });
1031-
1032- // ── Patch file re-upload & version protection ──────────────────────────────
1033-
1034- let uploadTestPatchUrl: string;
1035-
1036- // Adds upload-test.txt — applies cleanly to my-repo
1037- const UPLOAD_TEST_PATCH = [
1038- 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
1039- 'From: Original Author <original@example.com>',
1040- 'Date: Wed, 03 Jan 2024 10:00:00 +0000',
1041- 'Subject: [PATCH] Add upload-test.txt',
1042- '',
1043- '---',
1044- 'diff --git a/upload-test.txt b/upload-test.txt',
1045- 'new file mode 100644',
1046- 'index 0000000..9daeafb',
1047- '--- /dev/null',
1048- '+++ b/upload-test.txt',
1049- '@@ -0,0 +1 @@',
1050- '+upload test',
1051- '',
1052- ].join('\n');
1053-
1054- // Replacement: different author, same diff target
1055- const REPLACEMENT_PATCH = [
1056- 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
1057- 'From: Replaced Author <replaced@example.com>',
1058- 'Date: Thu, 04 Jan 2024 10:00:00 +0000',
1059- 'Subject: [PATCH] Add upload-test.txt (v2)',
1060- '',
1061- '---',
1062- 'diff --git a/upload-test.txt b/upload-test.txt',
1063- 'new file mode 100644',
1064- 'index 0000000..9daeafb',
1065- '--- /dev/null',
1066- '+++ b/upload-test.txt',
1067- '@@ -0,0 +1 @@',
1068- '+upload test v2',
1069- '',
1070- ].join('\n');
1071-
1072- test('create patch for re-upload tests', async () => {
1073- writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
1074- const page = await adminCtx.newPage();
1075- try {
1076- await page.goto(`${BASE}/my-repo/patches/new`);
1077- await page.fill('[name=title]', 'Upload test patch');
1078- await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
1079- await page.click('form[action$="/patches"] button[type=submit]');
1080- await page.waitForURL(/\/my-repo\/patches\/\d+/);
1081- uploadTestPatchUrl = page.url();
1082- } finally { await page.close(); }
1083- });
1084-
1085- test('upload patch file button is visible for admin on open patch', async () => {
1086- const page = await adminCtx.newPage();
1087- try {
1088- await page.goto(uploadTestPatchUrl);
1089- expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
1090- } finally { await page.close(); }
1091- });
1092-
1093- test('non-author non-admin cannot upload patch file', async () => {
1094- const aliceCtx = await loggedInContext('alice', 'password123');
1095- const page = await aliceCtx.newPage();
1096- try {
1097- const patchNum = uploadTestPatchUrl.split('/patches/')[1];
1098- const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
1099- multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
1100- maxRedirects: 0,
1101- });
1102- expect(resp.status()).toBe(403);
1103- } finally { await aliceCtx.close(); }
1104- });
1105-
1106- test('upload button hidden for non-author non-admin', async () => {
1107- const aliceCtx = await loggedInContext('alice', 'password123');
1108- const page = await aliceCtx.newPage();
1109- try {
1110- await page.goto(uploadTestPatchUrl);
1111- expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
1112- } finally { await aliceCtx.close(); }
1113- });
1114-
1115- test('admin can upload replacement patch file', async () => {
1116- writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
1117- const page = await adminCtx.newPage();
1118- try {
1119- await page.goto(uploadTestPatchUrl);
1120- await page.locator('details:has([name=patch_file]) summary').click();
1121- await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
1122- await page.locator('details:has([name=patch_file]) button[type=submit]').click();
1123- await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
1124- } finally { await page.close(); }
1125- });
1126-
1127- test('merge fails when version token is stale', async () => {
1128- // Patch that adds stale-version.txt — applies cleanly
1129- const STALE_PATCH = [
1130- 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
1131- 'From: Test User <test@example.com>',
1132- 'Date: Fri, 05 Jan 2024 10:00:00 +0000',
1133- 'Subject: [PATCH] Add stale-version.txt',
1134- '',
1135- '---',
1136- 'diff --git a/stale-version.txt b/stale-version.txt',
1137- 'new file mode 100644',
1138- 'index 0000000..9daeafb',
1139- '--- /dev/null',
1140- '+++ b/stale-version.txt',
1141- '@@ -0,0 +1 @@',
1142- '+stale',
1143- '',
1144- ].join('\n');
1145- const STALE_PATCH_V2 = STALE_PATCH
1146- .replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
1147- .replace('+stale', '+stale v2');
1148-
1149- writeTempFile('/tmp/stale.patch', STALE_PATCH);
1150- const page = await adminCtx.newPage();
1151- try {
1152- // Create the patch
1153- await page.goto(`${BASE}/my-repo/patches/new`);
1154- await page.fill('[name=title]', 'Stale version test');
1155- await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
1156- await page.click('form[action$="/patches"] button[type=submit]');
1157- await page.waitForURL(/\/my-repo\/patches\/\d+/);
1158- const stalePatchUrl = page.url();
1159- const patchNum = stalePatchUrl.split('/patches/')[1];
1160-
1161- // Capture the version the admin sees on the page
1162- const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
1163-
1164- // Author uploads a new patch file (simulated by admin here), bumping the version
1165- writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
1166- await page.locator('details:has([name=patch_file]) summary').click();
1167- await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
1168- await page.locator('details:has([name=patch_file]) button[type=submit]').click();
1169- await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
1170-
1171- // Admin tries to merge with the stale version — should be rejected
1172- const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
1173- form: { version: staleVersion },
1174- maxRedirects: 0,
1175- });
1176- expect(resp.status()).toBe(409);
1177- expect(await resp.text()).toContain('updated');
1178-
1179- // Patch status must still be open
1180- const checkResp = await page.request.get(stalePatchUrl);
1181- expect(checkResp.status()).toBe(200);
1182- expect(await checkResp.text()).toContain('open');
1183- } finally { await page.close(); }
1184- });
1185-
1186- test('merge succeeds with current version token after replacement upload', async () => {
1187- const page = await adminCtx.newPage();
1188- try {
1189- await page.goto(uploadTestPatchUrl);
1190- await page.click('form[action*="/merge"] button');
1191- await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
1192- expect(await page.locator('.patch-badge').textContent()).toBe('merged');
1193- // Merged commit should carry the replacement patch's author
1194- const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
1195- const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
1196- expect(authorName).toBe('Replaced Author');
1197- } finally { await page.close(); }
1198- });
1199-
1200- test('upload patch file button hidden on merged patch', async () => {
1201- const page = await adminCtx.newPage();
1202- try {
1203- await page.goto(uploadTestPatchUrl);
1204- expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
1205- } finally { await page.close(); }
1206- });
1207-
1208- test('POST to upload on merged patch returns 400', async () => {
1209- const patchNum = uploadTestPatchUrl.split('/patches/')[1];
1210- const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
1211- multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
1212- maxRedirects: 0,
1213- });
1214- expect(resp.status()).toBe(400);
1215- });
1216-});
1217-
1218-// ─── Pagination ───────────────────────────────────────────────────────────────
1219-
1220-describe('pagination', () => {
1221- let adminCtx: BrowserContext;
1222-
1223- beforeAll(async () => {
1224- adminCtx = await loggedInContext();
1225-
1226- // Create a repo dedicated to pagination testing
1227- const page = await adminCtx.newPage();
1228- try {
1229- await page.goto(`${BASE}/new`);
1230- await page.fill('[name=name]', 'paged-repo');
1231- await page.click('form[action="/new"] button[type=submit]');
1232- await page.waitForURL(`${BASE}/paged-repo`);
1233- } finally { await page.close(); }
1234-
1235- // Create 21 issues via the API (triggers page 2 at 20 per page)
1236- await bulkCreateIssues(adminCtx, 'paged-repo', 21);
1237-
1238- // Create 21 patches via browser (patch upload requires multipart)
1239- await bulkCreatePatches(adminCtx, 'paged-repo', 21);
1240- });
1241-
1242- afterAll(async () => { await adminCtx.close(); });
1243-
1244- // ── Repo list pagination ──────────────────────────────────────────────────
1245-
1246- test('repo list page 1 shows repos and no pagination when few repos', async () => {
1247- // With only a handful of test repos (< 20), there should be no pagination nav
1248- const page = await adminCtx.newPage();
1249- try {
1250- await page.goto(BASE);
1251- // Repos are shown
1252- expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
1253- } finally { await page.close(); }
1254- });
1255-
1256- // ── Issue pagination ──────────────────────────────────────────────────────
1257-
1258- test('issue list page 1 shows at most 20 items', async () => {
1259- const page = await adminCtx.newPage();
1260- try {
1261- await page.goto(`${BASE}/paged-repo/issues`);
1262- expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1263- } finally { await page.close(); }
1264- });
1265-
1266- test('issue list pagination nav appears when more than 20 issues', async () => {
1267- const page = await adminCtx.newPage();
1268- try {
1269- await page.goto(`${BASE}/paged-repo/issues`);
1270- expect(await page.locator('.pagination').isVisible()).toBe(true);
1271- } finally { await page.close(); }
1272- });
1273-
1274- test('issue list page 2 shows remaining issues', async () => {
1275- const page = await adminCtx.newPage();
1276- try {
1277- await page.goto(`${BASE}/paged-repo/issues?page=2`);
1278- const count = await page.locator('.issue-item').count();
1279- expect(count).toBeGreaterThan(0);
1280- expect(count).toBeLessThanOrEqual(20);
1281- } finally { await page.close(); }
1282- });
1283-
1284- test('issue list page 2 prev link goes to page 1', async () => {
1285- const page = await adminCtx.newPage();
1286- try {
1287- await page.goto(`${BASE}/paged-repo/issues?page=2`);
1288- const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
1289- expect(prevHref).toContain('page=1');
1290- } finally { await page.close(); }
1291- });
1292-
1293- test('issue list page 1 next link goes to page 2', async () => {
1294- const page = await adminCtx.newPage();
1295- try {
1296- await page.goto(`${BASE}/paged-repo/issues`);
1297- const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
1298- expect(nextHref).toContain('page=2');
1299- } finally { await page.close(); }
1300- });
1301-
1302- // ── Patch pagination ──────────────────────────────────────────────────────
1303-
1304- test('patch list page 1 shows at most 20 items', async () => {
1305- const page = await adminCtx.newPage();
1306- try {
1307- await page.goto(`${BASE}/paged-repo/patches`);
1308- expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1309- } finally { await page.close(); }
1310- });
1311-
1312- test('patch list pagination nav appears when more than 20 patches', async () => {
1313- const page = await adminCtx.newPage();
1314- try {
1315- await page.goto(`${BASE}/paged-repo/patches`);
1316- expect(await page.locator('.pagination').isVisible()).toBe(true);
1317- } finally { await page.close(); }
1318- });
1319-
1320- test('patch list page 2 shows remaining patches', async () => {
1321- const page = await adminCtx.newPage();
1322- try {
1323- await page.goto(`${BASE}/paged-repo/patches?page=2`);
1324- const count = await page.locator('.issue-item').count();
1325- expect(count).toBeGreaterThan(0);
1326- } finally { await page.close(); }
1327- });
1328-
1329- // ── Commit log pagination ─────────────────────────────────────────────────
1330-
1331- test('commit log with few commits shows no cursor nav', async () => {
1332- // my-repo has 1 commit — both newer and older links should be absent
1333- const page = await adminCtx.newPage();
1334- try {
1335- await page.goto(`${BASE}/my-repo/commits/main`);
1336- expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
1337- } finally { await page.close(); }
1338- });
1339-});
1340-
1341-// ─── Branch selector ──────────────────────────────────────────────────────────
1342-
1343-describe('branch selector', () => {
1344- let adminCtx: BrowserContext;
1345-
1346- beforeAll(async () => {
1347- adminCtx = await loggedInContext();
1348- // Add a second branch so the selector is meaningful
1349- await seedBranch('my-repo', 'dev');
1350- });
1351-
1352- afterAll(async () => { await adminCtx.close(); });
1353-
1354- test('branch selector appears on repo home', async () => {
1355- const page = await adminCtx.newPage();
1356- try {
1357- await page.goto(`${BASE}/my-repo`);
1358- expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1359- expect(await page.locator('.branch-select').inputValue()).toBe('main');
1360- } finally { await page.close(); }
1361- });
1362-
1363- test('branch selector shows all branches on repo home', async () => {
1364- const page = await adminCtx.newPage();
1365- try {
1366- await page.goto(`${BASE}/my-repo`);
1367- const options = await page.locator('.branch-select option').allTextContents();
1368- expect(options).toContain('main');
1369- expect(options).toContain('dev');
1370- } finally { await page.close(); }
1371- });
1372-
1373- test('branch selector appears on file tree with current ref selected', async () => {
1374- const page = await adminCtx.newPage();
1375- try {
1376- await page.goto(`${BASE}/my-repo/tree/main`);
1377- expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1378- expect(await page.locator('.branch-select').inputValue()).toBe('main');
1379- } finally { await page.close(); }
1380- });
1381-
1382- test('branch selector appears on commit log with current ref selected', async () => {
1383- const page = await adminCtx.newPage();
1384- try {
1385- await page.goto(`${BASE}/my-repo/commits/main`);
1386- expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1387- expect(await page.locator('.branch-select').inputValue()).toBe('main');
1388- } finally { await page.close(); }
1389- });
1390-
1391- test('branch selector appears on file blob', async () => {
1392- const page = await adminCtx.newPage();
1393- try {
1394- await page.goto(`${BASE}/my-repo/blob/main/README.md`);
1395- expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1396- expect(await page.locator('.branch-select').inputValue()).toBe('main');
1397- } finally { await page.close(); }
1398- });
1399-
1400- test('switching branch on commit log navigates to the selected branch', async () => {
1401- const page = await adminCtx.newPage();
1402- try {
1403- await page.goto(`${BASE}/my-repo/commits/main`);
1404- await page.locator('.branch-select').selectOption('dev');
1405- await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1406- await page.waitForURL(`${BASE}/my-repo/commits/dev`);
1407- expect(page.url()).toContain('/commits/dev');
1408- } finally { await page.close(); }
1409- });
1410-
1411- test('switching branch on file tree navigates to the selected branch', async () => {
1412- const page = await adminCtx.newPage();
1413- try {
1414- await page.goto(`${BASE}/my-repo/tree/main`);
1415- await page.locator('.branch-select').selectOption('dev');
1416- await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1417- await page.waitForURL(`${BASE}/my-repo/tree/dev`);
1418- expect(page.url()).toContain('/tree/dev');
1419- } finally { await page.close(); }
1420- });
1421-
1422- test('branch-switch route preserves subpath when switching tree', async () => {
1423- const page = await adminCtx.newPage();
1424- try {
1425- const resp = await page.request.get(
1426- `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`,
1427- { maxRedirects: 0 },
1428- ).catch(r => r);
1429- // 302 redirect to /my-repo/tree/dev/src/foo
1430- const loc = (resp as any).headers()?.['location'] ?? '';
1431- expect(loc).toContain('/tree/dev/src/foo');
1432- } finally { await page.close(); }
1433- });
1434-
1435- test('branch-switch route redirects commits view correctly', async () => {
1436- const page = await adminCtx.newPage();
1437- try {
1438- const resp = await page.request.get(
1439- `${BASE}/my-repo/branch-switch?view=commits&rev=dev`,
1440- { maxRedirects: 0 },
1441- ).catch(r => r);
1442- const loc = (resp as any).headers()?.['location'] ?? '';
1443- expect(loc).toContain('/commits/dev');
1444- } finally { await page.close(); }
1445- });
1446-
1447- test('branch-switch route redirects blob view correctly', async () => {
1448- const page = await adminCtx.newPage();
1449- try {
1450- const resp = await page.request.get(
1451- `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`,
1452- { maxRedirects: 0 },
1453- ).catch(r => r);
1454- const loc = (resp as any).headers()?.['location'] ?? '';
1455- expect(loc).toContain('/blob/dev/README.md');
1456- } finally { await page.close(); }
1457- });
1458-});
1459-
1460-// ─── Default branch settings ──────────────────────────────────────────────────
1461-
1462-describe('default branch settings', () => {
1463- // Runs after 'branch selector', so my-repo already has both main and dev branches.
1464- let adminCtx: BrowserContext;
1465-
1466- beforeAll(async () => { adminCtx = await loggedInContext(); });
1467- afterAll(async () => { await adminCtx.close(); });
1468-
1469- test('settings page shows default branch select', async () => {
1470- const page = await adminCtx.newPage();
1471- try {
1472- await page.goto(`${BASE}/my-repo/settings`);
1473- expect(await page.locator('#default_branch').isVisible()).toBe(true);
1474- const options = await page.locator('#default_branch option').allTextContents();
1475- expect(options).toContain('main');
1476- expect(options).toContain('dev');
1477- } finally { await page.close(); }
1478- });
1479-
1480- test('current default branch is pre-selected', async () => {
1481- const page = await adminCtx.newPage();
1482- try {
1483- await page.goto(`${BASE}/my-repo/settings`);
1484- expect(await page.locator('#default_branch').inputValue()).toBe('main');
1485- } finally { await page.close(); }
1486- });
1487-
1488- test('changing default branch saves and is reflected in the repo home', async () => {
1489- const page = await adminCtx.newPage();
1490- try {
1491- await page.goto(`${BASE}/my-repo/settings`);
1492- await page.locator('#default_branch').selectOption('dev');
1493- await page.click('form[action$="/settings"] button[type=submit]');
1494- expect(await page.locator('.form-success').isVisible()).toBe(true);
1495- // The select now shows dev as current
1496- expect(await page.locator('#default_branch').inputValue()).toBe('dev');
1497-
1498- // Repo home branch selector should reflect the new default
1499- await page.goto(`${BASE}/my-repo`);
1500- expect(await page.locator('.branch-select').inputValue()).toBe('dev');
1501- } finally { await page.close(); }
1502- });
1503-
1504- test('commit log link in repo nav uses the new default branch', async () => {
1505- const page = await adminCtx.newPage();
1506- try {
1507- await page.goto(`${BASE}/my-repo`);
1508- const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
1509- expect(commitsHref).toContain('/commits/dev');
1510- } finally { await page.close(); }
1511- });
1512-
1513- test('changing default branch back to main restores original state', async () => {
1514- const page = await adminCtx.newPage();
1515- try {
1516- await page.goto(`${BASE}/my-repo/settings`);
1517- await page.locator('#default_branch').selectOption('main');
1518- await page.click('form[action$="/settings"] button[type=submit]');
1519- expect(await page.locator('.form-success').isVisible()).toBe(true);
1520- expect(await page.locator('#default_branch').inputValue()).toBe('main');
1521- } finally { await page.close(); }
1522- });
1523-
1524- test('settings page shows hint instead of select when repo has no branches', async () => {
1525- // Create an empty repo (no commits → no branches)
1526- const page = await adminCtx.newPage();
1527- try {
1528- await page.goto(`${BASE}/new`);
1529- await page.fill('[name=name]', 'empty-for-branch-test');
1530- await page.click('form[action="/new"] button[type=submit]');
1531- await page.waitForURL(`${BASE}/empty-for-branch-test`);
1532-
1533- await page.goto(`${BASE}/empty-for-branch-test/settings`);
1534- expect(await page.locator('#default_branch').count()).toBe(0);
1535- expect(await page.locator('.form-hint').isVisible()).toBe(true);
1536- } finally { await page.close(); }
1537- });
1538-});
1539-
1540-// ─── File browser ─────────────────────────────────────────────────────────────
1541-
1542-describe('file browser', () => {
1543- // my-repo already has README.md + index.js from the 'repos' describe block.
1544- // We add a subdirectory here so we can test directory navigation.
1545- let adminCtx: BrowserContext;
1546-
1547- beforeAll(async () => {
1548- adminCtx = await loggedInContext();
1549- await seedSubdir('my-repo', 'src', {
1550- 'app.ts': 'export {};\n',
1551- 'README.md': '# src readme\n',
1552- });
1553- });
1554-
1555- afterAll(async () => { await adminCtx.close(); });
1556-
1557- test('repo home shows file tree instead of recent commits', async () => {
1558- const page = await adminCtx.newPage();
1559- try {
1560- await page.goto(`${BASE}/my-repo`);
1561- expect(await page.locator('.file-tree').isVisible()).toBe(true);
1562- expect(await page.locator('.repo-commits-section').count()).toBe(0);
1563- } finally { await page.close(); }
1564- });
1565-
1566- test('repo home file tree lists files and directories', async () => {
1567- const page = await adminCtx.newPage();
1568- try {
1569- await page.goto(`${BASE}/my-repo`);
1570- const names = await page.locator('.file-name a').allTextContents();
1571- expect(names).toContain('README.md');
1572- expect(names).toContain('index.js');
1573- expect(names).toContain('src');
1574- } finally { await page.close(); }
1575- });
1576-
1577- test('directories appear before files in file tree', async () => {
1578- const page = await adminCtx.newPage();
1579- try {
1580- await page.goto(`${BASE}/my-repo`);
1581- const names = await page.locator('.file-name a').allTextContents();
1582- const srcIdx = names.indexOf('src');
1583- const readmeIdx = names.indexOf('README.md');
1584- expect(srcIdx).toBeGreaterThanOrEqual(0);
1585- expect(readmeIdx).toBeGreaterThanOrEqual(0);
1586- expect(srcIdx).toBeLessThan(readmeIdx);
1587- } finally { await page.close(); }
1588- });
1589-
1590- test('no ".." entry at repository root', async () => {
1591- const page = await adminCtx.newPage();
1592- try {
1593- await page.goto(`${BASE}/my-repo`);
1594- const names = await page.locator('.file-name a').allTextContents();
1595- expect(names).not.toContain('..');
1596- } finally { await page.close(); }
1597- });
1598-
1599- test('clicking directory navigates into it', async () => {
1600- const page = await adminCtx.newPage();
1601- try {
1602- await page.goto(`${BASE}/my-repo`);
1603- await page.locator('.file-name a', { hasText: 'src' }).click();
1604- await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
1605- expect(page.url()).toContain('/tree/main/src');
1606- } finally { await page.close(); }
1607- });
1608-
1609- test('".." entry appears in subdirectory', async () => {
1610- const page = await adminCtx.newPage();
1611- try {
1612- await page.goto(`${BASE}/my-repo/tree/main/src`);
1613- const names = await page.locator('.file-name a').allTextContents();
1614- expect(names).toContain('..');
1615- } finally { await page.close(); }
1616- });
1617-
1618- test('".." at one level deep links to tree root', async () => {
1619- const page = await adminCtx.newPage();
1620- try {
1621- await page.goto(`${BASE}/my-repo/tree/main/src`);
1622- const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
1623- expect(upHref).toBe('/my-repo/tree/main');
1624- } finally { await page.close(); }
1625- });
1626-
1627- test('files in subdirectory show plain names, not full paths', async () => {
1628- const page = await adminCtx.newPage();
1629- try {
1630- await page.goto(`${BASE}/my-repo/tree/main/src`);
1631- const names = await page.locator('.file-name a').allTextContents();
1632- expect(names).toContain('app.ts');
1633- // Must NOT contain the full path with prefix
1634- expect(names).not.toContain('src/app.ts');
1635- expect(names).not.toContain('src/README.md');
1636- } finally { await page.close(); }
1637- });
1638-
1639- test('readme is shown below file tree on repo home', async () => {
1640- const page = await adminCtx.newPage();
1641- try {
1642- await page.goto(`${BASE}/my-repo`);
1643- const treeBox = await page.locator('.file-tree').boundingBox();
1644- const readmeBox = await page.locator('.readme-section').boundingBox();
1645- expect(treeBox).not.toBeNull();
1646- expect(readmeBox).not.toBeNull();
1647- expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
1648- } finally { await page.close(); }
1649- });
1650-
1651- test('readme in subdirectory is shown when present', async () => {
1652- const page = await adminCtx.newPage();
1653- try {
1654- await page.goto(`${BASE}/my-repo/tree/main/src`);
1655- expect(await page.locator('.readme-section').isVisible()).toBe(true);
1656- expect(await page.locator('.readme-section .markdown-body').innerHTML())
1657- .toContain('src readme');
1658- } finally { await page.close(); }
1659- });
1660-
1661- test('file tree on /tree/:ref also shows readme', async () => {
1662- const page = await adminCtx.newPage();
1663- try {
1664- await page.goto(`${BASE}/my-repo/tree/main`);
1665- expect(await page.locator('.file-tree').isVisible()).toBe(true);
1666- expect(await page.locator('.readme-section').isVisible()).toBe(true);
1667- } finally { await page.close(); }
1668- });
1669-});
1670-
1671-// ─── Releases ─────────────────────────────────────────────────────────────────
1672-
1673-describe('releases', () => {
1674- let adminCtx: BrowserContext;
1675- let aliceCtx: BrowserContext;
1676- let releaseUrl: string;
1677- let srcReleaseUrl: string;
1678- let releaseWithAssetsUrl: string;
1679-
1680- beforeAll(async () => {
1681- adminCtx = await loggedInContext();
1682- aliceCtx = await loggedInContext('alice', 'password123');
1683-
1684- // Create a dedicated repo with at least one commit
1685- const page = await adminCtx.newPage();
1686- try {
1687- await page.goto(`${BASE}/new`);
1688- await page.fill('[name=name]', 'releases-repo');
1689- await page.click('form[action="/new"] button[type=submit]');
1690- await page.waitForURL(`${BASE}/releases-repo`);
1691- } finally { await page.close(); }
1692-
1693- await seedRepo('releases-repo');
1694- });
1695-
1696- afterAll(async () => {
1697- await adminCtx.close();
1698- await aliceCtx.close();
1699- });
1700-
1701- // ── Navigation ──────────────────────────────────────────────────────────────
1702-
1703- test('releases tab visible in repo nav', async () => {
1704- const page = await adminCtx.newPage();
1705- try {
1706- await page.goto(`${BASE}/releases-repo`);
1707- expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
1708- } finally { await page.close(); }
1709- });
1710-
1711- test('releases list shows empty state when no releases', async () => {
1712- const page = await adminCtx.newPage();
1713- try {
1714- await page.goto(`${BASE}/releases-repo/releases`);
1715- expect(await page.locator('.empty-state').isVisible()).toBe(true);
1716- } finally { await page.close(); }
1717- });
1718-
1719- // ── Access control ──────────────────────────────────────────────────────────
1720-
1721- test('non-admin cannot access /releases/new', async () => {
1722- const page = await aliceCtx.newPage();
1723- try {
1724- const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
1725- expect(resp.status()).toBe(403);
1726- } finally { await page.close(); }
1727- });
1728-
1729- test('non-admin POST to /releases returns 403', async () => {
1730- const page = await aliceCtx.newPage();
1731- try {
1732- const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
1733- multipart: { name: 'Test', tag_name: 'v0.1.0' },
1734- maxRedirects: 0,
1735- });
1736- expect(resp.status()).toBe(403);
1737- } finally { await page.close(); }
1738- });
1739-
1740- test('unauthenticated user is redirected to login from /releases/new', async () => {
1741- const ctx = await browser.newContext();
1742- const page = await ctx.newPage();
1743- try {
1744- await page.goto(`${BASE}/releases-repo/releases/new`);
1745- expect(page.url()).toContain('/login');
1746- } finally { await ctx.close(); }
1747- });
1748-
1749- // ── Validation ──────────────────────────────────────────────────────────────
1750-
1751- test('missing release title shows error', async () => {
1752- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1753- multipart: {},
1754- });
1755- expect(await resp.text()).toContain('Release title is required');
1756- });
1757-
1758- test('create_tag checked but no tag name shows error', async () => {
1759- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1760- multipart: { name: 'Test', create_tag: 'on' },
1761- });
1762- expect(await resp.text()).toContain('Tag name is required');
1763- });
1764-
1765- // ── Create ──────────────────────────────────────────────────────────────────
1766-
1767- test('create a basic release', async () => {
1768- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1769- multipart: {
1770- create_tag: 'on',
1771- tag_name: 'v1.0.0',
1772- revision: 'main',
1773- name: 'First release',
1774- notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
1775- },
1776- maxRedirects: 0,
1777- });
1778- expect(resp.status()).toBe(302);
1779- const location = resp.headers()['location']!;
1780- expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
1781- releaseUrl = `${BASE}${location}`;
1782- });
1783-
1784- test('duplicate tag name shows error', async () => {
1785- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1786- multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
1787- });
1788- expect(await resp.text()).toContain('already exists');
1789- });
1790-
1791- // ── List ────────────────────────────────────────────────────────────────────
1792-
1793- test('release appears in list with tag badge', async () => {
1794- const page = await adminCtx.newPage();
1795- try {
1796- await page.goto(`${BASE}/releases-repo/releases`);
1797- expect(await page.locator('.release-item-title').textContent()).toContain('First release');
1798- expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1799- } finally { await page.close(); }
1800- });
1801-
1802- test('release list shows tag badge', async () => {
1803- const page = await adminCtx.newPage();
1804- try {
1805- await page.goto(`${BASE}/releases-repo/releases`);
1806- expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
1807- } finally { await page.close(); }
1808- });
1809-
1810- test('new release button hidden for non-admin', async () => {
1811- const page = await aliceCtx.newPage();
1812- try {
1813- await page.goto(`${BASE}/releases-repo/releases`);
1814- expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
1815- } finally { await page.close(); }
1816- });
1817-
1818- // ── Detail ──────────────────────────────────────────────────────────────────
1819-
1820- test('release detail shows title and tag badge', async () => {
1821- const page = await adminCtx.newPage();
1822- try {
1823- await page.goto(releaseUrl);
1824- expect(await page.locator('h2.page-title').textContent()).toBe('First release');
1825- expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1826- } finally { await page.close(); }
1827- });
1828-
1829- test('release notes rendered in detail view', async () => {
1830- const page = await adminCtx.newPage();
1831- try {
1832- await page.goto(releaseUrl);
1833- expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
1834- } finally { await page.close(); }
1835- });
1836-
1837- // ── Source archives ─────────────────────────────────────────────────────────
1838-
1839- test('create release with source code archives', async () => {
1840- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1841- multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
1842- maxRedirects: 0,
1843- });
1844- expect(resp.status()).toBe(302);
1845- const location = resp.headers()['location']!;
1846- srcReleaseUrl = `${BASE}${location}`;
1847- });
1848-
1849- test('zip and tar.gz archives appear in downloads', async () => {
1850- const page = await adminCtx.newPage();
1851- try {
1852- await page.goto(srcReleaseUrl);
1853- const assetNames = await page.locator('.asset-name').allTextContents();
1854- expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
1855- expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
1856- } finally { await page.close(); }
1857- });
1858-
1859- test('source archive download responds with 200', async () => {
1860- const page = await adminCtx.newPage();
1861- try {
1862- await page.goto(srcReleaseUrl);
1863- const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
1864- const resp = await page.request.get(`${BASE}${zipLink}`);
1865- expect(resp.status()).toBe(200);
1866- } finally { await page.close(); }
1867- });
1868-
1869- // ── File upload ─────────────────────────────────────────────────────────────
1870-
1871- test('create release with attached file', async () => {
1872- const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1873- multipart: {
1874- name: 'Asset release',
1875- create_tag: 'on',
1876- tag_name: 'v1.2.0',
1877- revision: 'main',
1878- files: {
1879- name: 'release-asset.txt',
1880- mimeType: 'text/plain',
1881- buffer: Buffer.from('binary-like content for testing\n'),
1882- },
1883- },
1884- maxRedirects: 0,
1885- });
1886- expect(resp.status()).toBe(302);
1887- const location = resp.headers()['location']!;
1888- releaseWithAssetsUrl = `${BASE}${location}`;
1889- });
1890-
1891- test('uploaded asset appears in downloads with filename and size', async () => {
1892- const page = await adminCtx.newPage();
1893- try {
1894- await page.goto(releaseWithAssetsUrl);
1895- const names = await page.locator('.asset-name').allTextContents();
1896- expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
1897- expect(await page.locator('.asset-size').isVisible()).toBe(true);
1898- } finally { await page.close(); }
1899- });
1900-
1901- test('asset download responds with 200', async () => {
1902- const page = await adminCtx.newPage();
1903- try {
1904- await page.goto(releaseWithAssetsUrl);
1905- const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
1906- const resp = await page.request.get(`${BASE}${link}`);
1907- expect(resp.status()).toBe(200);
1908- } finally { await page.close(); }
1909- });
1910-
1911- // ── Delete ──────────────────────────────────────────────────────────────────
1912-
1913- test('non-admin cannot delete a release', async () => {
1914- const page = await aliceCtx.newPage();
1915- try {
1916- const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1917- const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
1918- maxRedirects: 0,
1919- });
1920- expect(resp.status()).toBe(403);
1921- } finally { await page.close(); }
1922- });
1923-
1924- test('admin can delete a release', async () => {
1925- const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1926- const resp = await adminCtx.request.post(
1927- `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
1928- { maxRedirects: 0 },
1929- );
1930- expect(resp.status()).toBe(302);
1931- // Verify it's gone from the list
1932- const page = await adminCtx.newPage();
1933- try {
1934- await page.goto(`${BASE}/releases-repo/releases`);
1935- const titles = await page.locator('.release-item-title').allTextContents();
1936- expect(titles.some(t => t.includes('First release'))).toBe(false);
1937- } finally { await page.close(); }
1938- });
1939-
1940- // ── Pagination ──────────────────────────────────────────────────────────────
1941-
1942- test('release list shows at most 20 per page', async () => {
1943- // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
1944- // regardless of which browser-based tests above succeeded
1945- for (let i = 1; i <= 25; i++) {
1946- await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1947- multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
1948- maxRedirects: 0,
1949- }).catch(() => {});
1950- }
1951- const page = await adminCtx.newPage();
1952- try {
1953- await page.goto(`${BASE}/releases-repo/releases`);
1954- expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1955- } finally { await page.close(); }
1956- });
1957-
1958- test('pagination nav appears with more than 20 releases', async () => {
1959- const page = await adminCtx.newPage();
1960- try {
1961- await page.goto(`${BASE}/releases-repo/releases`);
1962- expect(await page.locator('.pagination').isVisible()).toBe(true);
1963- } finally { await page.close(); }
1964- });
1965-
1966- test('release list page 2 shows remaining releases', async () => {
1967- const page = await adminCtx.newPage();
1968- try {
1969- await page.goto(`${BASE}/releases-repo/releases?page=2`);
1970- const count = await page.locator('.issue-item').count();
1971- expect(count).toBeGreaterThan(0);
1972- expect(count).toBeLessThanOrEqual(20);
1973- } finally { await page.close(); }
1974- });
1975-
1976- test('source archive tar.zst appears in downloads', async () => {
1977- const page = await adminCtx.newPage();
1978- try {
1979- await page.goto(srcReleaseUrl);
1980- const assetNames = await page.locator('.asset-name').allTextContents();
1981- expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
1982- } finally { await page.close(); }
1983- });
1984-});
1985-
1986-// ─── Settings ─────────────────────────────────────────────────────────────────
1987-
1988-describe('settings', () => {
1989- let adminCtx: BrowserContext;
1990- let aliceCtx: BrowserContext;
1991- // Public key generated once in beforeAll, reused across SSH key tests
1992- let testPubKey: string;
1993-
1994- beforeAll(async () => {
1995- adminCtx = await loggedInContext();
1996- aliceCtx = await loggedInContext('alice', 'password123');
1997-
1998- // Generate a throwaway ed25519 key for SSH key tests.
1999- // Use Bun.spawn so the empty passphrase arg is passed correctly.
2000- const keyPath = '/tmp/hf-e2e-sshkey';
2001- await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
2002- const keygen = Bun.spawn(
2003- ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
2004- { stdout: 'ignore', stderr: 'ignore' },
2005- );
2006- await keygen.exited;
2007- testPubKey = await Bun.file(`${keyPath}.pub`).text();
2008- testPubKey = testPubKey.trim();
2009- await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
2010- });
2011-
2012- afterAll(async () => {
2013- await adminCtx.close();
2014- await aliceCtx.close();
2015- });
2016-
2017- test('settings page requires auth', async () => {
2018- const ctx = await browser.newContext();
2019- const page = await ctx.newPage();
2020- try {
2021- await page.goto(`${BASE}/settings`);
2022- expect(page.url()).toContain('/login');
2023- } finally { await ctx.close(); }
2024- });
2025-
2026- test('settings page loads for logged-in user', async () => {
2027- const page = await adminCtx.newPage();
2028- try {
2029- await page.goto(`${BASE}/settings`);
2030- expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
2031- } finally { await page.close(); }
2032- });
2033-
2034- // ── Password ──────────────────────────────────────────────────────────────
2035-
2036- test('password change with mismatched passwords shows error', async () => {
2037- const page = await aliceCtx.newPage();
2038- try {
2039- await page.goto(`${BASE}/settings`);
2040- await page.fill('[name=new_password]', 'newpass123');
2041- await page.fill('[name=confirm_password]', 'different456');
2042- await page.click('form[action="/settings/password"] button[type=submit]');
2043- await page.waitForURL(/\/settings/);
2044- expect(page.url()).toContain('error');
2045- } finally { await page.close(); }
2046- });
2047-
2048- test('password change with wrong current password shows error', async () => {
2049- const page = await aliceCtx.newPage();
2050- try {
2051- await page.goto(`${BASE}/settings`);
2052- await page.fill('[name=current_password]', 'wrongpassword');
2053- await page.fill('[name=new_password]', 'newpass123');
2054- await page.fill('[name=confirm_password]', 'newpass123');
2055- await page.click('form[action="/settings/password"] button[type=submit]');
2056- await page.waitForURL(/\/settings/);
2057- expect(page.url()).toContain('error');
2058- } finally { await page.close(); }
2059- });
2060-
2061- test('password change too short shows error', async () => {
2062- const page = await aliceCtx.newPage();
2063- try {
2064- await page.goto(`${BASE}/settings`);
2065- await page.fill('[name=current_password]', 'password123');
2066- await page.fill('[name=new_password]', 'short');
2067- await page.fill('[name=confirm_password]', 'short');
2068- await page.click('form[action="/settings/password"] button[type=submit]');
2069- await page.waitForURL(/\/settings/);
2070- expect(page.url()).toContain('error');
2071- } finally { await page.close(); }
2072- });
2073-
2074- // ── SSH keys ──────────────────────────────────────────────────────────────
2075-
2076- test('add SSH key with unsupported key type shows error', async () => {
2077- const page = await adminCtx.newPage();
2078- try {
2079- await page.goto(`${BASE}/settings`);
2080- await page.fill('#ssh_key_name', 'Bad key');
2081- await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
2082- await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2083- await page.waitForURL(/\/settings/);
2084- expect(page.url()).toContain('error');
2085- } finally { await page.close(); }
2086- });
2087-
2088- test('add valid SSH key shows success and key appears in list', async () => {
2089- const page = await adminCtx.newPage();
2090- try {
2091- await page.goto(`${BASE}/settings`);
2092- await page.fill('#ssh_key_name', 'My Laptop');
2093- await page.fill('#ssh_public_key', testPubKey);
2094- await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2095- await page.waitForURL(/\/settings/);
2096- expect(page.url()).toContain('success=ssh_key_added');
2097- await page.goto(`${BASE}/settings`);
2098- expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
2099- } finally { await page.close(); }
2100- });
2101-
2102- test('add duplicate SSH key shows error', async () => {
2103- const page = await adminCtx.newPage();
2104- try {
2105- await page.goto(`${BASE}/settings`);
2106- await page.fill('#ssh_key_name', 'Duplicate');
2107- await page.fill('#ssh_public_key', testPubKey);
2108- await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2109- await page.waitForURL(/\/settings/);
2110- expect(page.url()).toContain('error');
2111- } finally { await page.close(); }
2112- });
2113-
2114- test('delete SSH key removes it from list', async () => {
2115- const page = await adminCtx.newPage();
2116- try {
2117- await page.goto(`${BASE}/settings`);
2118- // Click the Remove button for the key added above
2119- await page.click('form[action="/settings/ssh-keys/delete"] button');
2120- await page.waitForURL(/\/settings/);
2121- expect(page.url()).toContain('success=ssh_key_deleted');
2122- await page.goto(`${BASE}/settings`);
2123- expect(await page.locator('.ssh-key-name').count()).toBe(0);
2124- } finally { await page.close(); }
2125- });
2126-
2127- // ── Admin user management ────────────────────────────────────────────────
2128-
2129- test('admin can create a new user account', async () => {
2130- const page = await adminCtx.newPage();
2131- try {
2132- await page.goto(`${BASE}/settings`);
2133- await page.fill('#new_username', 'charlie');
2134- await page.fill('#new_user_password', 'charliepw1');
2135- await page.click('form[action="/admin/users"] button[type=submit]');
2136- await page.waitForURL(/\/settings/);
2137- expect(page.url()).toContain('success=user_created');
2138- } finally { await page.close(); }
2139- });
2140-
2141- test('admin cannot create duplicate username', async () => {
2142- const page = await adminCtx.newPage();
2143- try {
2144- await page.goto(`${BASE}/settings`);
2145- await page.fill('#new_username', 'charlie');
2146- await page.fill('#new_user_password', 'charliepw1');
2147- await page.click('form[action="/admin/users"] button[type=submit]');
2148- await page.waitForURL(/\/settings/);
2149- expect(page.url()).toContain('error');
2150- } finally { await page.close(); }
2151- });
2152-
2153- test('admin cannot create user with invalid username characters', async () => {
2154- const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
2155- form: { username: 'bad user!', password: 'password123' },
2156- maxRedirects: 0,
2157- });
2158- expect(resp.status()).toBe(302);
2159- const location = resp.headers()['location'] ?? '';
2160- expect(location).toContain('error');
2161- });
2162-
2163- test('non-admin gets 403 when creating user', async () => {
2164- const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
2165- form: { username: 'hacker', password: 'password123' },
2166- maxRedirects: 0,
2167- });
2168- expect(resp.status()).toBe(403);
2169- });
2170-
2171- test('admin can delete user account', async () => {
2172- const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
2173- form: { username: 'charlie' },
2174- maxRedirects: 0,
2175- });
2176- expect(resp.status()).toBe(302);
2177- expect(resp.headers()['location']).toContain('success=user_deleted');
2178- });
2179-
2180- test('admin cannot delete the admin account', async () => {
2181- const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
2182- form: { username: 'admin' },
2183- maxRedirects: 0,
2184- });
2185- expect(resp.status()).toBe(302);
2186- expect(resp.headers()['location']).toContain('error');
2187- });
2188-
2189- test('settings page has no git identity section', async () => {
2190- const page = await adminCtx.newPage();
2191- try {
2192- await page.goto(`${BASE}/settings`);
2193- expect(await page.locator('text=Git Identity').count()).toBe(0);
2194- expect(await page.locator('[name=git_name]').count()).toBe(0);
2195- expect(await page.locator('[name=git_email]').count()).toBe(0);
2196- } finally { await page.close(); }
2197- });
2198-
2199- test('git identity route no longer exists', async () => {
2200- const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
2201- form: { git_name: 'Test', git_email: 'test@example.com' },
2202- maxRedirects: 0,
2203- });
2204- expect(resp.status()).toBe(404);
2205- });
2206-});
2207-
2208-// ─── Repository deletion ──────────────────────────────────────────────────────
2209-
2210-describe('repository deletion', () => {
2211- let adminCtx: BrowserContext;
2212-
2213- beforeAll(async () => {
2214- adminCtx = await loggedInContext();
2215-
2216- // Create a repo to delete
2217- const page = await adminCtx.newPage();
2218- try {
2219- await page.goto(`${BASE}/new`);
2220- await page.fill('[name=name]', 'deleteme-repo');
2221- await page.click('form[action="/new"] button[type=submit]');
2222- await page.waitForURL(`${BASE}/deleteme-repo`);
2223- } finally { await page.close(); }
2224- });
2225-
2226- afterAll(async () => { await adminCtx.close(); });
2227-
2228- test('admin can delete repository', async () => {
2229- const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
2230- maxRedirects: 0,
2231- });
2232- expect(resp.status()).toBe(302);
2233- expect(resp.headers()['location']).toBe('/');
2234- });
2235-
2236- test('deleted repository returns 404', async () => {
2237- const page = await adminCtx.newPage();
2238- try {
2239- const resp = await page.request.get(`${BASE}/deleteme-repo`);
2240- expect(resp.status()).toBe(404);
2241- } finally { await page.close(); }
2242- });
2243-
2244- test('deleted repository no longer appears in list', async () => {
2245- const page = await adminCtx.newPage();
2246- try {
2247- await page.goto(BASE);
2248- expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
2249- } finally { await page.close(); }
2250- });
2251-
2252- test('non-admin cannot delete repository', async () => {
2253- const aliceCtx = await loggedInContext('alice', 'password123');
2254- const page = await aliceCtx.newPage();
2255- try {
2256- const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
2257- maxRedirects: 0,
2258- });
2259- expect(resp.status()).toBe(403);
2260- } finally {
2261- await page.close();
2262- await aliceCtx.close();
2263- }
2264- });
2265-});
2266-
2267-// ─── 404 handling ─────────────────────────────────────────────────────────────
2268-
2269-describe('404 handling', () => {
2270- let adminCtx: BrowserContext;
2271-
2272- beforeAll(async () => { adminCtx = await loggedInContext(); });
2273- afterAll(async () => { await adminCtx.close(); });
2274-
2275- test('non-existent repository returns 404', async () => {
2276- const page = await adminCtx.newPage();
2277- try {
2278- const resp = await page.request.get(`${BASE}/no-such-repo`);
2279- expect(resp.status()).toBe(404);
2280- } finally { await page.close(); }
2281- });
2282-
2283- test('non-existent issue returns 404', async () => {
2284- const page = await adminCtx.newPage();
2285- try {
2286- const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
2287- expect(resp.status()).toBe(404);
2288- } finally { await page.close(); }
2289- });
2290-
2291- test('non-existent commit returns 404', async () => {
2292- const page = await adminCtx.newPage();
2293- try {
2294- const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
2295- expect(resp.status()).toBe(404);
2296- } finally { await page.close(); }
2297- });
2298-
2299- test('non-existent file blob returns 404', async () => {
2300- const page = await adminCtx.newPage();
2301- try {
2302- const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
2303- expect(resp.status()).toBe(404);
2304- } finally { await page.close(); }
2305- });
2306-
2307- test('non-existent patch returns 404', async () => {
2308- const page = await adminCtx.newPage();
2309- try {
2310- const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
2311- expect(resp.status()).toBe(404);
2312- } finally { await page.close(); }
2313- });
2314-
2315- test('non-existent release returns 404', async () => {
2316- const page = await adminCtx.newPage();
2317- try {
2318- const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
2319- expect(resp.status()).toBe(404);
2320- } finally { await page.close(); }
2321- });
2322-});
2323-
2324-// ─── Issue editing and deletion ───────────────────────────────────────────────
2325-
2326-describe('issue editing', () => {
2327- let adminCtx: BrowserContext;
2328- let aliceCtx: BrowserContext;
2329- let issueUrl: string;
2330-
2331- beforeAll(async () => {
2332- adminCtx = await loggedInContext();
2333- aliceCtx = await loggedInContext('alice', 'password123');
2334-
2335- // Create an issue to edit
2336- const page = await adminCtx.newPage();
2337- try {
2338- await page.goto(`${BASE}/my-repo/issues/new`);
2339- await page.fill('[name=title]', 'Issue to edit');
2340- await page.fill('[name=body]', 'Original body.');
2341- await page.click('form[action$="/issues"] button[type=submit]');
2342- await page.waitForURL(/\/my-repo\/issues\/\d+/);
2343- issueUrl = page.url();
2344- } finally { await page.close(); }
2345- });
2346-
2347- afterAll(async () => {
2348- await adminCtx.close();
2349- await aliceCtx.close();
2350- });
2351-
2352- test('author can edit issue title and body', async () => {
2353- const page = await adminCtx.newPage();
2354- try {
2355- await page.goto(issueUrl);
2356- // Edit title via title form
2357- await page.click('details.title-edit-details summary');
2358- await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
2359- await page.click('.title-edit-form-area [type=submit]');
2360- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2361- expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
2362- // Edit body via inline form
2363- await page.click('.timeline-author .inline-edit-details summary');
2364- await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
2365- await page.click('.inline-edit-form-area [type=submit]');
2366- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2367- } finally { await page.close(); }
2368- });
2369-
2370- test('edited marker appears after editing', async () => {
2371- const page = await adminCtx.newPage();
2372- try {
2373- await page.goto(issueUrl);
2374- expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
2375- } finally { await page.close(); }
2376- });
2377-
2378- test('non-author non-admin cannot edit issue', async () => {
2379- const page = await aliceCtx.newPage();
2380- try {
2381- const issueNum = issueUrl.split('/issues/')[1];
2382- const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
2383- form: { title: 'Hacked title', edit_body: '' },
2384- maxRedirects: 0,
2385- });
2386- expect(resp.status()).toBe(403);
2387- } finally { await page.close(); }
2388- });
2389-
2390- test('author can edit issue comment', async () => {
2391- const page = await adminCtx.newPage();
2392- try {
2393- await page.goto(issueUrl);
2394- // Add a comment first
2395- await page.fill('textarea[name=body]', 'Comment to edit.');
2396- await page.click('form[action*="/comments"] button[type=submit]');
2397- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2398-
2399- // Edit the comment
2400- const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
2401- await commentItem.locator('.inline-edit-details summary').click();
2402- await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
2403- await commentItem.locator('.inline-edit-form-area [type=submit]').click();
2404- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2405- expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
2406- } finally { await page.close(); }
2407- });
2408-
2409- test('non-admin user can create an issue', async () => {
2410- const page = await aliceCtx.newPage();
2411- try {
2412- await page.goto(`${BASE}/my-repo/issues/new`);
2413- await page.fill('[name=title]', "Alice's issue");
2414- await page.click('form[action$="/issues"] button[type=submit]');
2415- await page.waitForURL(/\/my-repo\/issues\/\d+/);
2416- expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
2417- } finally { await page.close(); }
2418- });
2419-
2420- test('non-admin cannot comment on a closed issue', async () => {
2421- // Close the issue as admin first
2422- const issueNum = issueUrl.split('/issues/')[1];
2423- await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
2424-
2425- const page = await aliceCtx.newPage();
2426- try {
2427- const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
2428- form: { body: 'comment on closed issue' },
2429- maxRedirects: 0,
2430- });
2431- // Non-admin gets redirected (silently ignored), not an error
2432- expect(resp.status()).toBe(302);
2433- // The comment should NOT appear
2434- await page.goto(issueUrl);
2435- const bodies = await page.locator('.timeline-body').allTextContents();
2436- expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
2437- } finally { await page.close(); }
2438- });
2439-
2440- test('admin can delete issue', async () => {
2441- const issueNum = issueUrl.split('/issues/')[1];
2442- const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
2443- maxRedirects: 0,
2444- });
2445- expect(resp.status()).toBe(302);
2446- // Issue should be gone
2447- const page = await adminCtx.newPage();
2448- try {
2449- const checkResp = await page.request.get(issueUrl);
2450- expect(checkResp.status()).toBe(404);
2451- } finally { await page.close(); }
2452- });
2453-});
2454-
2455-// ─── Repository description update ───────────────────────────────────────────
2456-
2457-describe('repo description', () => {
2458- let adminCtx: BrowserContext;
2459-
2460- beforeAll(async () => { adminCtx = await loggedInContext(); });
2461- afterAll(async () => { await adminCtx.close(); });
2462-
2463- test('updating repo description is reflected on list page', async () => {
2464- const page = await adminCtx.newPage();
2465- try {
2466- await page.goto(`${BASE}/my-repo/settings`);
2467- await page.fill('[name=description]', 'A freshly updated description');
2468- await page.click('form[action$="/settings"] button[type=submit]');
2469- expect(await page.locator('.form-success').isVisible()).toBe(true);
2470-
2471- await page.goto(BASE);
2472- const desc = await page.locator('.repo-description').allTextContents();
2473- expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
2474- } finally { await page.close(); }
2475- });
2476-});
2477-
2478-// ─── Issue and patch templates ────────────────────────────────────────────────
2479-
2480-describe('issue and patch templates', () => {
2481- let adminCtx: BrowserContext;
2482-
2483- beforeAll(async () => { adminCtx = await loggedInContext(); });
2484- afterAll(async () => { await adminCtx.close(); });
2485-
2486- test('issue template can be saved and is prefilled on new issue form', async () => {
2487- const page = await adminCtx.newPage();
2488- try {
2489- await page.goto(`${BASE}/my-repo/settings`);
2490- await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
2491- await page.click('form[action$="/settings"] button[type=submit]');
2492- expect(await page.locator('.form-success').isVisible()).toBe(true);
2493-
2494- await page.goto(`${BASE}/my-repo/issues/new`);
2495- const body = await page.locator('[name=body]').inputValue();
2496- expect(body).toContain('## Steps to reproduce');
2497- expect(body).toContain('## Expected behavior');
2498- } finally { await page.close(); }
2499- });
2500-
2501- test('patch template can be saved and is prefilled on new patch form', async () => {
2502- const page = await adminCtx.newPage();
2503- try {
2504- await page.goto(`${BASE}/my-repo/settings`);
2505- await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
2506- await page.click('form[action$="/settings"] button[type=submit]');
2507- expect(await page.locator('.form-success').isVisible()).toBe(true);
2508-
2509- await page.goto(`${BASE}/my-repo/patches/new`);
2510- const desc = await page.locator('[name=description]').inputValue();
2511- expect(desc).toContain('## Summary');
2512- expect(desc).toContain('## Testing');
2513- } finally { await page.close(); }
2514- });
2515-
2516- test('clearing the issue template removes prefill', async () => {
2517- const page = await adminCtx.newPage();
2518- try {
2519- await page.goto(`${BASE}/my-repo/settings`);
2520- await page.fill('[name=issue_template]', '');
2521- await page.click('form[action$="/settings"] button[type=submit]');
2522- expect(await page.locator('.form-success').isVisible()).toBe(true);
2523-
2524- await page.goto(`${BASE}/my-repo/issues/new`);
2525- const body = await page.locator('[name=body]').inputValue();
2526- expect(body).toBe('');
2527- } finally { await page.close(); }
2528- });
2529-
2530- test('clearing the patch template removes prefill', async () => {
2531- const page = await adminCtx.newPage();
2532- try {
2533- await page.goto(`${BASE}/my-repo/settings`);
2534- await page.fill('[name=patch_template]', '');
2535- await page.click('form[action$="/settings"] button[type=submit]');
2536- expect(await page.locator('.form-success').isVisible()).toBe(true);
2537-
2538- await page.goto(`${BASE}/my-repo/patches/new`);
2539- const desc = await page.locator('[name=description]').inputValue();
2540- expect(desc).toBe('');
2541- } finally { await page.close(); }
2542- });
2543-});
2544-
2545-// ─── Commit signing ───────────────────────────────────────────────────────────
2546-// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
2547-
2548-describe('commit signing', () => {
2549- let adminCtx: BrowserContext;
2550-
2551- beforeAll(async () => { adminCtx = await loggedInContext(); });
2552- afterAll(async () => { await adminCtx.close(); });
2553-
2554- test('allowed_signers file is generated at startup', () => {
2555- const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
2556- expect(existsSync(allowedSignersPath)).toBe(true);
2557- const content = readFileSync(allowedSignersPath, 'utf8');
2558- expect(content).toContain('namespaces="git"');
2559- expect(content).toContain('ssh-ed25519');
2560- });
2561-
2562- test('merged commit has a gpgsig header', async () => {
2563- const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2564- // Find the patch commit specifically by subject
2565- const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2566- expect(hash).toBeTruthy();
2567- const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2568- expect(obj).toContain('gpgsig');
2569- });
2570-
2571- test('unsigned commits have no gpgsig header', async () => {
2572- const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2573- // Initial commit was created by seedRepo (plain git commit, not hearthforge)
2574- const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2575- expect(hash).toBeTruthy();
2576- const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2577- expect(obj).not.toContain('gpgsig');
2578- });
2579-
2580- test('commit log shows verified badge on signed commit', async () => {
2581- const page = await adminCtx.newPage();
2582- try {
2583- await page.goto(`${BASE}/my-repo/commits/main`);
2584- // Find the commit-item for the merged patch by subject text
2585- const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
2586- expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
2587- } finally { await page.close(); }
2588- });
2589-
2590- test('commit log shows no sig badge on unsigned commit', async () => {
2591- const page = await adminCtx.newPage();
2592- try {
2593- await page.goto(`${BASE}/my-repo/commits/main`);
2594- // Initial commit was not signed via hearthforge
2595- const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
2596- expect(await initialItem.locator('.sig-badge').count()).toBe(0);
2597- } finally { await page.close(); }
2598- });
2599-
2600- test('commit detail shows verified signature row for signed commit', async () => {
2601- const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2602- const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2603- const page = await adminCtx.newPage();
2604- try {
2605- await page.goto(`${BASE}/my-repo/commit/${hash}`);
2606- const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2607- expect(await sigRow.isVisible()).toBe(true);
2608- expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
2609- } finally { await page.close(); }
2610- });
2611-
2612- test('commit detail shows no signature row for unsigned commit', async () => {
2613- const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2614- const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2615- const page = await adminCtx.newPage();
2616- try {
2617- await page.goto(`${BASE}/my-repo/commit/${hash}`);
2618- const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2619- expect(await sigRow.count()).toBe(0);
2620- } finally { await page.close(); }
2621- });
2622-});
2623-
2624-// ─── Repo sorting and pinning ─────────────────────────────────────────────────
2625-
2626-describe('repo sorting and pinning', () => {
2627- let adminCtx: BrowserContext;
2628-
2629- beforeAll(async () => {
2630- adminCtx = await loggedInContext();
2631- // Create two repos with predictable names: sort-aaa (created first/older),
2632- // sort-zzz (created second/newer). This lets us verify both name order and
2633- // creation-time order independently.
2634- const page = await adminCtx.newPage();
2635- try {
2636- await page.goto(`${BASE}/new`);
2637- await page.fill('[name=name]', 'sort-aaa');
2638- await page.click('form[action="/new"] button[type=submit]');
2639- await page.waitForURL(`${BASE}/sort-aaa`);
2640-
2641- await page.goto(`${BASE}/new`);
2642- await page.fill('[name=name]', 'sort-zzz');
2643- await page.click('form[action="/new"] button[type=submit]');
2644- await page.waitForURL(`${BASE}/sort-zzz`);
2645- } finally { await page.close(); }
2646- });
2647-
2648- afterAll(async () => { await adminCtx.close(); });
2649-
2650- test('sort dropdown is visible on repo list page', async () => {
2651- const page = await adminCtx.newPage();
2652- try {
2653- await page.goto(BASE);
2654- const options = await page.locator('.repo-sort-select option').allTextContents();
2655- expect(options.some(t => t.includes('Newest'))).toBe(true);
2656- expect(options.some(t => t.includes('Name'))).toBe(true);
2657- } finally { await page.close(); }
2658- });
2659-
2660- test('newest option is selected by default', async () => {
2661- // Use a fresh context to ensure no repo_sort cookie is set.
2662- const ctx = await browser.newContext();
2663- const page = await ctx.newPage();
2664- try {
2665- await login(page, 'admin', ADMIN_PASS);
2666- await page.goto(BASE);
2667- expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
2668- } finally { await ctx.close(); }
2669- });
2670-
2671- test('Go button is hidden with JS and works without JS', async () => {
2672- const ctx = await browser.newContext({ javaScriptEnabled: false });
2673- const page = await ctx.newPage();
2674- try {
2675- await login(page, 'admin', ADMIN_PASS);
2676- await page.goto(BASE);
2677- // Go button visible without JS
2678- expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
2679- // Select name sort and submit via Go button
2680- await page.locator('.repo-sort-select').selectOption('name');
2681- await page.locator('form[action="/sort"] button[type=submit]').click();
2682- await page.waitForURL(BASE + '/');
2683- expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2684- } finally { await ctx.close(); }
2685- });
2686-
2687- test('selecting name sort sets cookie and persists on next visit', async () => {
2688- const page = await adminCtx.newPage();
2689- try {
2690- await page.goto(BASE);
2691- await Promise.all([
2692- page.waitForURL(BASE + '/'),
2693- page.locator('.repo-sort-select').selectOption('name'),
2694- ]);
2695- expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2696- // Navigate away and back to confirm cookie persists
2697- await page.goto(`${BASE}/my-repo`);
2698- await page.goto(BASE);
2699- expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2700- } finally {
2701- // Reset cookie so subsequent tests start from the default sort.
2702- await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2703- await page.close();
2704- }
2705- });
2706-
2707- test('default sort shows newer repo before older repo', async () => {
2708- const page = await adminCtx.newPage();
2709- try {
2710- await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2711- await page.goto(`${BASE}/?q=sort-`);
2712- const names = await page.locator('.repo-name').allTextContents();
2713- expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
2714- } finally { await page.close(); }
2715- });
2716-
2717- test('name sort shows repos in alphabetical order', async () => {
2718- const page = await adminCtx.newPage();
2719- try {
2720- await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
2721- await page.goto(`${BASE}/?q=sort-`);
2722- const names = await page.locator('.repo-name').allTextContents();
2723- expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
2724- } finally {
2725- await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2726- await page.close();
2727- }
2728- });
2729-
2730- test('pinning a repo shows pinned badge on list page', async () => {
2731- const page = await adminCtx.newPage();
2732- try {
2733- await page.goto(`${BASE}/sort-aaa/settings`);
2734- await page.check('[name=is_pinned]');
2735- await page.click('form[action$="/settings"] button[type=submit]');
2736- expect(await page.locator('.form-success').isVisible()).toBe(true);
2737-
2738- await page.goto(BASE);
2739- const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
2740- expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
2741- } finally { await page.close(); }
2742- });
2743-
2744- test('pinned repo appears before unpinned repos regardless of creation order', async () => {
2745- const page = await adminCtx.newPage();
2746- try {
2747- // sort-aaa is pinned; default (newest) sort would normally show sort-zzz
2748- // first since it's newer — but pinned repos float to the top.
2749- await page.goto(`${BASE}/?q=sort-`);
2750- const names = await page.locator('.repo-name').allTextContents();
2751- expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
2752- } finally { await page.close(); }
2753- });
2754-
2755- test('unpinning a repo removes the pinned badge', async () => {
2756- const page = await adminCtx.newPage();
2757- try {
2758- await page.goto(`${BASE}/sort-aaa/settings`);
2759- await page.uncheck('[name=is_pinned]');
2760- await page.click('form[action$="/settings"] button[type=submit]');
2761- expect(await page.locator('.form-success').isVisible()).toBe(true);
2762-
2763- await page.goto(BASE);
2764- const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
2765- expect(await card.locator('.badge-pinned').count()).toBe(0);
2766- } finally { await page.close(); }
2767- });
2768-});
2769-
2770-// ─── File editing ─────────────────────────────────────────────────────────────
2771-
2772-describe('file editing', () => {
2773- let adminCtx: BrowserContext;
2774-
2775- beforeAll(async () => {
2776- adminCtx = await loggedInContext();
2777- // Create a dedicated repo so edits don't interfere with other tests
2778- const page = await adminCtx.newPage();
2779- try {
2780- await page.goto(`${BASE}/new`);
2781- await page.fill('[name=name]', 'edit-repo');
2782- await page.click('form[action="/new"] button[type=submit]');
2783- await page.waitForURL(`${BASE}/edit-repo`);
2784- } finally { await page.close(); }
2785- await seedRepo('edit-repo');
2786- });
2787-
2788- afterAll(async () => { await adminCtx.close(); });
2789-
2790- test('Edit button appears on text file blob when viewing a branch as admin', async () => {
2791- const page = await adminCtx.newPage();
2792- try {
2793- await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2794- const editBtn = page.locator('a[href*="/edit/main/index.js"]');
2795- expect(await editBtn.isVisible()).toBe(true);
2796- expect(await editBtn.textContent()).toBe('Edit');
2797- } finally { await page.close(); }
2798- });
2799-
2800- test('Edit button does not appear when viewing a commit SHA', async () => {
2801- const sha = await getHeadCommit('edit-repo');
2802- const page = await adminCtx.newPage();
2803- try {
2804- await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
2805- expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
2806- } finally { await page.close(); }
2807- });
2808-
2809- test('Edit button does not appear for unauthenticated visitors', async () => {
2810- const ctx = await browser.newContext();
2811- const page = await ctx.newPage();
2812- try {
2813- await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2814- expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
2815- } finally {
2816- await page.close();
2817- await ctx.close();
2818- }
2819- });
2820-
2821- test('edit page loads with file content pre-filled', async () => {
2822- const page = await adminCtx.newPage();
2823- try {
2824- await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2825- expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
2826- const content = await page.locator('textarea[name=content]').inputValue();
2827- expect(content).toContain('hello');
2828- const msg = await page.locator('textarea[name=message]').inputValue();
2829- expect(msg).toBe('Edited index.js');
2830- } finally { await page.close(); }
2831- });
2832-
2833- test('edit page shows which branch will be committed to', async () => {
2834- const page = await adminCtx.newPage();
2835- try {
2836- await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2837- expect(await page.content()).toContain('main');
2838- } finally { await page.close(); }
2839- });
2840-
2841- test('edit page returns 404 for non-branch ref', async () => {
2842- const sha = await getHeadCommit('edit-repo');
2843- const page = await adminCtx.newPage();
2844- try {
2845- const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2846- expect(resp.status()).toBe(404);
2847- } finally { await page.close(); }
2848- });
2849-
2850- test('submitting edit creates a new commit and redirects to blob view', async () => {
2851- const page = await adminCtx.newPage();
2852- try {
2853- await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2854- await page.fill('textarea[name=content]', 'console.log("edited");\n');
2855- await page.fill('textarea[name=message]', 'Update index.js via web editor');
2856- await page.locator('.form-actions button[type=submit]').click();
2857- await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
2858- // The commit detail view should show the commit message
2859- expect(await page.content()).toContain('Update index.js via web editor');
2860- } finally { await page.close(); }
2861- });
2862-
2863- test('edit commit has a gpgsig header (is signed)', async () => {
2864- const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
2865- const hash = (
2866- await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet()
2867- ).text().trim();
2868- expect(hash).toBeTruthy();
2869- const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2870- expect(obj).toContain('gpgsig');
2871- });
2872-
2873- test('edit commit shows verified badge in commit log', async () => {
2874- const page = await adminCtx.newPage();
2875- try {
2876- await page.goto(`${BASE}/edit-repo/commits/main`);
2877- const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
2878- expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
2879- } finally { await page.close(); }
2880- });
2881-
2882- test('GET edit page returns 404 for non-branch ref', async () => {
2883- const sha = await getHeadCommit('edit-repo');
2884- const page = await adminCtx.newPage();
2885- try {
2886- const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2887- expect(resp.status()).toBe(404);
2888- } finally { await page.close(); }
2889- });
2890-});
2891-
2892-// ─── Labels ───────────────────────────────────────────────────────────────────
2893-
2894-describe('labels', () => {
2895- let adminCtx: BrowserContext;
2896- let issueUrl: string;
2897- let patchUrl: string;
2898-
2899- const VALID_PATCH = [
2900- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
2901- 'From: Test User <test@example.com>',
2902- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
2903- 'Subject: [PATCH] Add label-test.txt',
2904- '',
2905- '---',
2906- 'diff --git a/label-test.txt b/label-test.txt',
2907- 'new file mode 100644',
2908- 'index 0000000..9daeafb',
2909- '--- /dev/null',
2910- '+++ b/label-test.txt',
2911- '@@ -0,0 +1 @@',
2912- '+x',
2913- '',
2914- ].join('\n');
2915-
2916- beforeAll(async () => {
2917- adminCtx = await loggedInContext();
2918-
2919- // Create a dedicated repo for label tests
2920- const page = await adminCtx.newPage();
2921- try {
2922- await page.goto(`${BASE}/new`);
2923- await page.fill('[name=name]', 'label-repo');
2924- await page.click('form[action="/new"] button[type=submit]');
2925- await page.waitForURL(`${BASE}/label-repo`);
2926- } finally { await page.close(); }
2927-
2928- // Create an issue
2929- const issuePage = await adminCtx.newPage();
2930- try {
2931- await issuePage.goto(`${BASE}/label-repo/issues/new`);
2932- await issuePage.fill('[name=title]', 'Labelled issue');
2933- await issuePage.click('form[action$="/issues"] button[type=submit]');
2934- await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
2935- issueUrl = issuePage.url();
2936- } finally { await issuePage.close(); }
2937-
2938- // Create a patch
2939- writeTempFile('/tmp/label-test.patch', VALID_PATCH);
2940- const patchPage = await adminCtx.newPage();
2941- try {
2942- await patchPage.goto(`${BASE}/label-repo/patches/new`);
2943- await patchPage.fill('[name=title]', 'Labelled patch');
2944- await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
2945- await patchPage.click('form[action$="/patches"] button[type=submit]');
2946- await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
2947- patchUrl = patchPage.url();
2948- } finally { await patchPage.close(); }
2949- });
2950-
2951- afterAll(async () => { await adminCtx.close(); });
2952-
2953- test('create label in repo settings', async () => {
2954- const page = await adminCtx.newPage();
2955- try {
2956- await page.goto(`${BASE}/label-repo/settings`);
2957- await page.fill('input[name=name]', 'bug');
2958- await page.locator('input[type=color][name=color]').evaluate(
2959- (el: any) => { el.value = '#ff0000'; },
2960- );
2961- await page.click('form[action$="/settings/labels"] button[type=submit]');
2962- await page.waitForURL(/\/label-repo\/settings/);
2963- expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
2964- } finally { await page.close(); }
2965- });
2966-
2967- test('create a second label', async () => {
2968- const page = await adminCtx.newPage();
2969- try {
2970- await page.goto(`${BASE}/label-repo/settings`);
2971- await page.fill('input[name=name]', 'enhancement');
2972- await page.locator('input[type=color][name=color]').evaluate(
2973- (el: any) => { el.value = '#00aa00'; },
2974- );
2975- await page.click('form[action$="/settings/labels"] button[type=submit]');
2976- await page.waitForURL(/\/label-repo\/settings/);
2977- const badges = await page.locator('.label-settings-name').allTextContents();
2978- expect(badges).toContain('bug');
2979- expect(badges).toContain('enhancement');
2980- } finally { await page.close(); }
2981- });
2982-
2983- test('duplicate label name is rejected', async () => {
2984- const page = await adminCtx.newPage();
2985- try {
2986- await page.goto(`${BASE}/label-repo/settings`);
2987- await page.fill('input[name=name]', 'bug');
2988- await page.locator('input[type=color][name=color]').evaluate(
2989- (el: any) => { el.value = '#0000ff'; },
2990- );
2991- await page.click('form[action$="/settings/labels"] button[type=submit]');
2992- await page.waitForURL(/\/label-repo\/settings/);
2993- expect(await page.locator('.form-error').isVisible()).toBe(true);
2994- } finally { await page.close(); }
2995- });
2996-
2997- test('non-admin cannot create labels', async () => {
2998- const ctx = await browser.newContext();
2999- try {
3000- const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
3001- form: { name: 'nope', color: '#123456' },
3002- maxRedirects: 0,
3003- });
3004- // Unauthenticated → redirected to /login
3005- expect(r.status()).toBe(302);
3006- expect(r.headers()['location']).toContain('/login');
3007- } finally { await ctx.close(); }
3008- });
3009-
3010- test('assign label to issue', async () => {
3011- const page = await adminCtx.newPage();
3012- try {
3013- await page.goto(issueUrl);
3014- await page.selectOption('select[name=label_id]', { label: 'bug' });
3015- await page.click('form[action$="/labels/add"] button[type=submit]');
3016- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
3017- expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
3018- } finally { await page.close(); }
3019- });
3020-
3021- test('label appears on issue list', async () => {
3022- const page = await adminCtx.newPage();
3023- try {
3024- await page.goto(`${BASE}/label-repo/issues`);
3025- const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
3026- expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
3027- } finally { await page.close(); }
3028- });
3029-
3030- test('filter issues by label shows only matching issues', async () => {
3031- // Create a second issue without the label
3032- const createPage = await adminCtx.newPage();
3033- try {
3034- await createPage.goto(`${BASE}/label-repo/issues/new`);
3035- await createPage.fill('[name=title]', 'Unlabelled issue');
3036- await createPage.click('form[action$="/issues"] button[type=submit]');
3037- await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
3038- } finally { await createPage.close(); }
3039-
3040- // Open filter popup and apply label filter
3041- const page = await adminCtx.newPage();
3042- try {
3043- await page.goto(`${BASE}/label-repo/issues`);
3044- // Get the label id from the checkbox
3045- const checkbox = page.locator('.label-filter-item input[name=labels]').first();
3046- const labelId = await checkbox.getAttribute('value');
3047- expect(labelId).toBeTruthy();
3048-
3049- // Navigate with the filter applied via URL
3050- await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
3051- const titles = await page.locator('.issue-title').allTextContents();
3052- expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
3053- expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
3054- } finally { await page.close(); }
3055- });
3056-
3057- test('filter popup is visible without JS', async () => {
3058- // details/summary is a native HTML element — verify it renders
3059- const page = await adminCtx.newPage();
3060- try {
3061- await page.goto(`${BASE}/label-repo/issues`);
3062- expect(await page.locator('details.label-filter').isVisible()).toBe(true);
3063- expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
3064- } finally { await page.close(); }
3065- });
3066-
3067- test('remove label from issue', async () => {
3068- const page = await adminCtx.newPage();
3069- try {
3070- await page.goto(issueUrl);
3071- await page.click('form[action$="/labels/remove"] button[type=submit]');
3072- await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
3073- // Label badge should no longer appear in the labels row
3074- const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
3075- expect(labelBadges).not.toContain('bug');
3076- } finally { await page.close(); }
3077- });
3078-
3079- test('assign label to patch', async () => {
3080- const page = await adminCtx.newPage();
3081- try {
3082- await page.goto(patchUrl);
3083- await page.selectOption('select[name=label_id]', { label: 'enhancement' });
3084- await page.click('form[action$="/labels/add"] button[type=submit]');
3085- await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
3086- expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
3087- } finally { await page.close(); }
3088- });
3089-
3090- test('label appears on patch list', async () => {
3091- const page = await adminCtx.newPage();
3092- try {
3093- await page.goto(`${BASE}/label-repo/patches`);
3094- const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
3095- expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
3096- } finally { await page.close(); }
3097- });
3098-
3099- test('filter patches by label', async () => {
3100- const page = await adminCtx.newPage();
3101- try {
3102- await page.goto(`${BASE}/label-repo/patches`);
3103- // Find the checkbox for the 'enhancement' label specifically
3104- const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
3105- .locator('input[name=labels]');
3106- const labelId = await checkbox.getAttribute('value');
3107- expect(labelId).toBeTruthy();
3108-
3109- await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
3110- const titles = await page.locator('.issue-title').allTextContents();
3111- expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
3112- } finally { await page.close(); }
3113- });
3114-
3115- test('remove label from patch', async () => {
3116- const page = await adminCtx.newPage();
3117- try {
3118- await page.goto(patchUrl);
3119- await page.click('form[action$="/labels/remove"] button[type=submit]');
3120- await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
3121- const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
3122- expect(labelBadges).not.toContain('enhancement');
3123- } finally { await page.close(); }
3124- });
3125-
3126- test('delete label removes it from settings list', async () => {
3127- const page = await adminCtx.newPage();
3128- try {
3129- await page.goto(`${BASE}/label-repo/settings`);
3130- const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
3131- await bugItem.locator('form[action$="/labels/delete"] button').click();
3132- await page.waitForURL(/\/label-repo\/settings/);
3133- const badges = await page.locator('.label-settings-name').allTextContents();
3134- expect(badges).not.toContain('bug');
3135- } finally { await page.close(); }
3136- });
3137-
3138- test('deleted label no longer appears in filter popup', async () => {
3139- const page = await adminCtx.newPage();
3140- try {
3141- await page.goto(`${BASE}/label-repo/issues`);
3142- const filterLabels = await page.locator('.label-filter-item').allTextContents();
3143- expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
3144- } finally { await page.close(); }
3145- });
3146-});
3147-
3148-// ─── User label management ────────────────────────────────────────────────────
3149-
3150-describe('user label management', () => {
3151- let adminCtx: BrowserContext;
3152- let aliceCtx: BrowserContext;
3153- // URL of an issue owned by alice
3154- let aliceIssueUrl: string;
3155- // URL of an issue owned by admin
3156- let adminIssueUrl: string;
3157- // label IDs, fetched from the filter inputs
3158- let bugLabelId: string;
3159-
3160- const VALID_PATCH = [
3161- 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
3162- 'From: Test User <test@example.com>',
3163- 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
3164- 'Subject: [PATCH] Add ulm-test.txt',
3165- '',
3166- '---',
3167- 'diff --git a/ulm-test.txt b/ulm-test.txt',
3168- 'new file mode 100644',
3169- 'index 0000000..9daeafb',
3170- '--- /dev/null',
3171- '+++ b/ulm-test.txt',
3172- '@@ -0,0 +1 @@',
3173- '+x',
3174- '',
3175- ].join('\n');
3176-
3177- beforeAll(async () => {
3178- adminCtx = await loggedInContext();
3179- aliceCtx = await loggedInContext('alice', 'password123');
3180-
3181- // Create dedicated repo
3182- const repoPage = await adminCtx.newPage();
3183- try {
3184- await repoPage.goto(`${BASE}/new`);
3185- await repoPage.fill('[name=name]', 'ulm-repo');
3186- await repoPage.click('form[action="/new"] button[type=submit]');
3187- await repoPage.waitForURL(`${BASE}/ulm-repo`);
3188- } finally { await repoPage.close(); }
3189-
3190- // Create labels 'bug' and 'feature'
3191- for (const name of ['bug', 'feature']) {
3192- const p = await adminCtx.newPage();
3193- try {
3194- await p.goto(`${BASE}/ulm-repo/settings`);
3195- await p.fill('input[name=name]', name);
3196- await p.click('form[action$="/settings/labels"] button[type=submit]');
3197- await p.waitForURL(/\/ulm-repo\/settings/);
3198- } finally { await p.close(); }
3199- }
3200-
3201- // Create an issue owned by admin
3202- const adminIssuePage = await adminCtx.newPage();
3203- try {
3204- await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
3205- await adminIssuePage.fill('[name=title]', "Admin's issue");
3206- await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
3207- await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
3208- adminIssueUrl = adminIssuePage.url();
3209- } finally { await adminIssuePage.close(); }
3210-
3211- // Create an issue owned by alice
3212- const aliceIssuePage = await aliceCtx.newPage();
3213- try {
3214- await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
3215- await aliceIssuePage.fill('[name=title]', "Alice's issue");
3216- await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
3217- await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
3218- aliceIssueUrl = aliceIssuePage.url();
3219- } finally { await aliceIssuePage.close(); }
3220-
3221- // Grab the bug label id from the filter popup
3222- const filterPage = await adminCtx.newPage();
3223- try {
3224- await filterPage.goto(`${BASE}/ulm-repo/issues`);
3225- const checkbox = filterPage.locator('.label-filter-item')
3226- .filter({ hasText: 'bug' })
3227- .locator('input[name=labels]');
3228- bugLabelId = (await checkbox.getAttribute('value')) ?? '';
3229- } finally { await filterPage.close(); }
3230- });
3231-
3232- afterAll(async () => {
3233- await adminCtx.close();
3234- await aliceCtx.close();
3235- });
3236-
3237- // ── Settings ──
3238-
3239- test('allow_user_labels checkbox is present in repo settings', async () => {
3240- const page = await adminCtx.newPage();
3241- try {
3242- await page.goto(`${BASE}/ulm-repo/settings`);
3243- expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
3244- } finally { await page.close(); }
3245- });
3246-
3247- test('allow_user_labels is off by default', async () => {
3248- const page = await adminCtx.newPage();
3249- try {
3250- await page.goto(`${BASE}/ulm-repo/settings`);
3251- expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
3252- } finally { await page.close(); }
3253- });
3254-
3255- // ── Label checkboxes hidden when setting is off ──
3256-
3257- test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
3258- const page = await aliceCtx.newPage();
3259- try {
3260- await page.goto(`${BASE}/ulm-repo/issues/new`);
3261- expect(await page.locator('.label-checkbox-list').count()).toBe(0);
3262- } finally { await page.close(); }
3263- });
3264-
3265- test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
3266- const page = await aliceCtx.newPage();
3267- try {
3268- await page.goto(`${BASE}/ulm-repo/patches/new`);
3269- expect(await page.locator('.label-checkbox-list').count()).toBe(0);
3270- } finally { await page.close(); }
3271- });
3272-
3273- test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
3274- const page = await adminCtx.newPage();
3275- try {
3276- await page.goto(`${BASE}/ulm-repo/issues/new`);
3277- expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
3278- } finally { await page.close(); }
3279- });
3280-
3281- // ── Enable the setting ──
3282-
3283- test('admin can enable allow_user_labels', async () => {
3284- const page = await adminCtx.newPage();
3285- try {
3286- await page.goto(`${BASE}/ulm-repo/settings`);
3287- await page.check('input[name=allow_user_labels]');
3288- await page.click('form[action$="/settings"] button[type=submit]');
3289- await page.waitForURL(/\/ulm-repo\/settings/);
3290- // Verify it persisted
3291- await page.goto(`${BASE}/ulm-repo/settings`);
3292- expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
3293- } finally { await page.close(); }
3294- });
3295-
3296- // ── Label checkboxes visible when setting is on ──
3297-
3298- test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
3299- const page = await aliceCtx.newPage();
3300- try {
3301- await page.goto(`${BASE}/ulm-repo/issues/new`);
3302- expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
3303- const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
3304- expect(labels).toContain('bug');
3305- expect(labels).toContain('feature');
3306- } finally { await page.close(); }
3307- });
3308-
3309- test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
3310- const page = await aliceCtx.newPage();
3311- try {
3312- await page.goto(`${BASE}/ulm-repo/patches/new`);
3313- expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
3314- } finally { await page.close(); }
3315- });
3316-
3317- // ── Creating with labels selected ──
3318-
3319- test('non-admin can create issue with label selected', async () => {
3320- const page = await aliceCtx.newPage();
3321- try {
3322- await page.goto(`${BASE}/ulm-repo/issues/new`);
3323- await page.fill('[name=title]', 'Issue with label');
3324- // Check the 'bug' label checkbox
3325- await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
3326- .locator('input[type=checkbox]').check();
3327- await page.click('form[action$="/issues"] button[type=submit]');
3328- await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
3329- const badges = await page.locator('.label-badge').allTextContents();
3330- expect(badges).toContain('bug');
3331- } finally { await page.close(); }
3332- });
3333-
3334- test('non-admin can create patch with label selected', async () => {
3335- writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
3336- const page = await aliceCtx.newPage();
3337- try {
3338- await page.goto(`${BASE}/ulm-repo/patches/new`);
3339- await page.fill('[name=title]', 'Patch with label');
3340- await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
3341- await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
3342- .locator('input[type=checkbox]').check();
3343- await page.click('form[action$="/patches"] button[type=submit]');
3344- await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
3345- const badges = await page.locator('.label-badge').allTextContents();
3346- expect(badges).toContain('feature');
3347- } finally { await page.close(); }
3348- });
3349-
3350- test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
3351- // Temporarily disable the setting, post with label_ids, re-enable
3352- await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
3353- form: { description: '', default_branch: 'main' }, // no allow_user_labels
3354- maxRedirects: 0,
3355- }).catch(() => {});
3356-
3357- const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
3358- form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
3359- maxRedirects: 0,
3360- }).catch(() => null);
3361- // Should redirect to the new issue
3362- const location = resp?.headers()['location'] ?? '';
3363- const issueNum = location.split('/issues/')[1];
3364-
3365- if (issueNum) {
3366- const page = await aliceCtx.newPage();
3367- try {
3368- await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
3369- const badges = await page.locator('.label-badge').allTextContents();
3370- expect(badges).not.toContain('bug');
3371- } finally { await page.close(); }
3372- }
3373-
3374- // Re-enable for subsequent tests
3375- await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
3376- form: { description: '', default_branch: 'main', allow_user_labels: '1' },
3377- maxRedirects: 0,
3378- }).catch(() => {});
3379- });
3380-
3381- // ── Add/remove labels on existing items ──
3382-
3383- test('non-admin can add label to their own issue', async () => {
3384- const issueNum = aliceIssueUrl.split('/issues/')[1];
3385- const page = await aliceCtx.newPage();
3386- try {
3387- await page.goto(aliceIssueUrl);
3388- await page.selectOption('select[name=label_id]', { label: 'bug' });
3389- await page.click('form[action$="/labels/add"] button[type=submit]');
3390- await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
3391- expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
3392- } finally { await page.close(); }
3393- });
3394-
3395- test('non-admin can remove label from their own issue', async () => {
3396- const issueNum = aliceIssueUrl.split('/issues/')[1];
3397- const page = await aliceCtx.newPage();
3398- try {
3399- await page.goto(aliceIssueUrl);
3400- await page.click('form[action$="/labels/remove"] button[type=submit]');
3401- await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
3402- const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
3403- expect(badges).not.toContain('bug');
3404- } finally { await page.close(); }
3405- });
3406-
3407- test('non-admin cannot add label to another user\'s issue', async () => {
3408- const issueNum = adminIssueUrl.split('/issues/')[1];
3409- const resp = await aliceCtx.request.post(
3410- `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
3411- { form: { label_id: bugLabelId }, maxRedirects: 0 },
3412- );
3413- expect(resp.status()).toBe(403);
3414- });
3415-
3416- test('unauthenticated user gets 401 adding a label', async () => {
3417- const issueNum = aliceIssueUrl.split('/issues/')[1];
3418- const ctx = await browser.newContext();
3419- try {
3420- const resp = await ctx.request.post(
3421- `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
3422- { form: { label_id: bugLabelId }, maxRedirects: 0 },
3423- );
3424- expect(resp.status()).toBe(401);
3425- } finally { await ctx.close(); }
3426- });
3427-
3428- // ── Disable setting and verify enforcement ──
3429-
3430- test('admin can disable allow_user_labels', async () => {
3431- const page = await adminCtx.newPage();
3432- try {
3433- await page.goto(`${BASE}/ulm-repo/settings`);
3434- await page.uncheck('input[name=allow_user_labels]');
3435- await page.click('form[action$="/settings"] button[type=submit]');
3436- await page.waitForURL(/\/ulm-repo\/settings/);
3437- await page.goto(`${BASE}/ulm-repo/settings`);
3438- expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
3439- } finally { await page.close(); }
3440- });
3441-
3442- test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
3443- const issueNum = aliceIssueUrl.split('/issues/')[1];
3444- const resp = await aliceCtx.request.post(
3445- `${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
3446- { form: { label_id: bugLabelId }, maxRedirects: 0 },
3447- );
3448- expect(resp.status()).toBe(403);
3449- });
3450-
3451- test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
3452- const page = await aliceCtx.newPage();
3453- try {
3454- await page.goto(`${BASE}/ulm-repo/issues/new`);
3455- expect(await page.locator('.label-checkbox-list').count()).toBe(0);
3456- } finally { await page.close(); }
3457- });
3458-});
Mtests/helpers.ts
@@ -1,12 +1,21 @@
1-import { $ } from 'bun';
21 import { createServer } from 'net';
32 import { rmSync, mkdirSync, writeFileSync } from 'fs';
3+import { spawnSync as nodeSpawnSync } from 'child_process';
44 import path from 'path';
55 import type { Page } from 'playwright';
6+import config from '../src/config.ts';
7+import { createApp } from '../src/app.ts';
8+import { resetDb } from '../src/db/index.ts';
69
710 export const DATA_DIR = './data-test';
811 export const ADMIN_PASS = 'testpass123';
912
13+// Override config for tests — this runs at import time, before any app code
14+// reads these values, so no preload script is needed.
15+config.DATA_DIR = path.resolve(DATA_DIR);
16+config.RATE_LIMIT_DISABLED = true;
17+config.SSH_DISABLED = true;
18+
1019 /** Bind to port 0 and return the OS-assigned free port number. */
1120 function getFreePort(): Promise<number> {
1221 return new Promise((resolve, reject) => {
@@ -23,46 +32,30 @@ function getFreePort(): Promise<number> {
2332 export let PORT = 0;
2433 export let BASE = '';
2534
35+/** Server handle — an Elysia app instance running in-process. */
36+export type ServerHandle = Awaited<ReturnType<typeof createApp>>;
37+
2638 export async function setupTestEnv() {
2739 PORT = await getFreePort();
2840 BASE = `http://localhost:${PORT}`;
29-
3041 rmSync(DATA_DIR, { recursive: true, force: true });
3142 mkdirSync(`${DATA_DIR}/repos`, { recursive: true });
3243
33- const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], {
44+ nodeSpawnSync('bun', ['run', 'src/db/init.ts'], {
3445 env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS },
35- stdout: 'pipe',
36- stderr: 'pipe',
46+ stdio: 'ignore',
3747 });
38- await init.exited;
48+
49+ // Reopen DB connection to the freshly-created database file.
50+ resetDb();
3951 }
4052
41-export function spawnServer() {
42- return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
43- env: {
44- ...process.env,
45- PORT: String(PORT),
46- DATA_DIR,
47- RATE_LIMIT_DISABLED: 'true',
48- SSH_DISABLED: 'true',
49- },
50- stdout: 'ignore',
51- stderr: 'ignore',
52- });
53+export async function spawnServer(): Promise<ServerHandle> {
54+ return createApp(PORT);
5355 }
5456
55-export async function waitForServer(maxMs = 10_000) {
56- const deadline = Date.now() + maxMs;
57- while (Date.now() < deadline) {
58- try {
59- await fetch(BASE);
60- return;
61- } catch {
62- await Bun.sleep(150);
63- }
64- }
65- throw new Error('Server did not start in time');
57+export async function killServer(app: ServerHandle): Promise<void> {
58+ await app.stop(true);
6659 }
6760
6861 export async function login(
@@ -82,21 +75,29 @@ export async function logout(page: Page) {
8275 await page.waitForURL(BASE + '/');
8376 }
8477
78+const git = (args: string[], cwd?: string) =>
79+ nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: 'ignore' });
80+
81+export function gitOutput(args: string[], cwd?: string): string {
82+ const r = nodeSpawnSync('git', [...(cwd ? ['-C', cwd] : []), ...args], { stdio: ['ignore', 'pipe', 'ignore'] });
83+ return r.stdout?.toString().trim() ?? '';
84+}
85+
8586 /** Push an initial commit into a bare repo that already exists on disk. */
86-export async function seedRepo(name: string) {
87+export function seedRepo(name: string) {
8788 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
8889 const tmp = `/tmp/hf-seed-${Date.now()}`;
8990 try {
90- await $`git clone ${repoPath} ${tmp}`.quiet();
91- await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
92- await $`git -C ${tmp} config user.name "Test"`.quiet();
91+ git(['clone', repoPath, tmp]);
92+ git(['config', 'user.email', 'test@test.com'], tmp);
93+ git(['config', 'user.name', 'Test'], tmp);
9394 writeFileSync(`${tmp}/README.md`, `# ${name}\n`);
9495 writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`);
95- await $`git -C ${tmp} add -A`.quiet();
96- await $`git -C ${tmp} commit -m "Initial commit"`.quiet();
97- await $`git -C ${tmp} push origin HEAD:main`.quiet();
96+ git(['add', '-A'], tmp);
97+ git(['commit', '-m', 'Initial commit'], tmp);
98+ git(['push', 'origin', 'HEAD:main'], tmp);
9899 } finally {
99- await $`rm -rf ${tmp}`.quiet().nothrow();
100+ rmSync(tmp, { recursive: true, force: true });
100101 }
101102 }
102103
@@ -105,42 +106,45 @@ export function writeTempFile(path: string, content: string) {
105106 }
106107
107108 /** Commit a subdirectory with the given files into an existing repo on main. */
108-export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
109+export function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
109110 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
110111 const tmp = `/tmp/hf-subdir-${Date.now()}`;
111112 try {
112- await $`git clone ${repoDir} ${tmp}`.quiet();
113- await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
114- await $`git -C ${tmp} config user.name "Test"`.quiet();
113+ git(['clone', repoDir, tmp]);
114+ git(['config', 'user.email', 'test@test.com'], tmp);
115+ git(['config', 'user.name', 'Test'], tmp);
115116 mkdirSync(path.join(tmp, dirPath), { recursive: true });
116117 for (const [fileName, content] of Object.entries(files)) {
117118 writeFileSync(path.join(tmp, dirPath, fileName), content);
118119 }
119- await $`git -C ${tmp} add -A`.quiet();
120- await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet();
121- await $`git -C ${tmp} push origin HEAD:main`.quiet();
120+ git(['add', '-A'], tmp);
121+ git(['commit', '-m', `Add ${dirPath}`], tmp);
122+ git(['push', 'origin', 'HEAD:main'], tmp);
122123 } finally {
123- await $`rm -rf ${tmp}`.quiet().nothrow();
124+ rmSync(tmp, { recursive: true, force: true });
124125 }
125126 }
126127
127128 /** Return the HEAD commit hash of a repo. */
128-export async function getHeadCommit(repoName: string): Promise<string> {
129+export function getHeadCommit(repoName: string): string {
129130 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
130- return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim();
131+ const result = nodeSpawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], {
132+ stdio: ['ignore', 'pipe', 'ignore'],
133+ });
134+ return result.stdout?.toString().trim() ?? '';
131135 }
132136
133137 /** Create a new branch in an existing repo (from current HEAD). */
134-export async function seedBranch(name: string, branchName: string) {
138+export function seedBranch(name: string, branchName: string) {
135139 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
136140 const tmp = `/tmp/hf-branch-${Date.now()}`;
137141 try {
138- await $`git clone ${repoPath} ${tmp}`.quiet();
139- await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
140- await $`git -C ${tmp} config user.name "Test"`.quiet();
141- await $`git -C ${tmp} checkout -b ${branchName}`.quiet();
142- await $`git -C ${tmp} push origin ${branchName}`.quiet();
142+ git(['clone', repoPath, tmp]);
143+ git(['config', 'user.email', 'test@test.com'], tmp);
144+ git(['config', 'user.name', 'Test'], tmp);
145+ git(['checkout', '-b', branchName], tmp);
146+ git(['push', 'origin', branchName], tmp);
143147 } finally {
144- await $`rm -rf ${tmp}`.quiet().nothrow();
148+ rmSync(tmp, { recursive: true, force: true });
145149 }
146150 }