various small improvements

AuthorKonata <konata@posteo.jp>
Date
Commita2f612efc6917fa4f6c1f660012b6525328474bf
Parent45c693a
24 files changed, 577 insertions(+), 585 deletions(-)
Msrc/app.ts
@@ -49,6 +49,30 @@ export async function createApp(port: number) {
4949 prefix: "/",
5050 }),
5151 )
52+ .onRequest(({ request }) => {
53+ // CSRF defense-in-depth: reject mutating requests whose Origin
54+ // header is present but does not match the Host. Modern browsers
55+ // attach Origin to all cross-site mutating requests, so this
56+ // catches what SameSite=Lax cookies would have allowed (top-level
57+ // POSTs from same-origin contexts are unaffected). Non-browser
58+ // clients (git push, curl) typically omit Origin and pass through.
59+ const m = request.method;
60+ if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
61+ return;
62+ const origin = request.headers.get("origin");
63+ if (!origin) return;
64+ const host = request.headers.get("host");
65+ let originHost: string;
66+ try {
67+ originHost = new URL(origin).host;
68+ } catch {
69+ return new Response("Bad Origin", { status: 403 });
70+ }
71+ if (!host || originHost !== host)
72+ return new Response("Cross-origin request rejected", {
73+ status: 403,
74+ });
75+ })
5276 .onAfterHandle(({ response }) => {
5377 if (response instanceof Response) {
5478 response.headers.set("Content-Security-Policy", CSP);
Msrc/constants.ts
@@ -73,6 +73,7 @@ export const MAX_MD_CACHE = 50;
7373 export const MAX_FILE_CACHE = 500;
7474 export const MAX_DIFF_CACHE = 500;
7575 export const MAX_PATCH_CACHE = 100;
76+export const PATCH_CACHE_TTL_MS = 60 * 60 * 1000;
7677 export const MAX_BRANCH_CACHE = 200;
7778 export const MAX_TAG_CACHE = 200;
7879 export const REF_CACHE_TTL_MS = 30_000;
Msrc/db/index.ts
@@ -373,9 +373,7 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
373373 )`);
374374
375375 // Indexes for common query patterns (safe to run repeatedly)
376-sqlite.run(
377- "CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)",
378-);
376+sqlite.run("CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)");
379377 sqlite.run(
380378 "CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)",
381379 );
@@ -400,8 +398,15 @@ sqlite.run(
400398 sqlite.run(
401399 "CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id)",
402400 );
401+sqlite.run("CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)");
402+sqlite.run(
403+ "CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)",
404+);
405+sqlite.run(
406+ "CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status)",
407+);
403408 sqlite.run(
404- "CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)",
409+ "CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status)",
405410 );
406411
407412 // Clean up expired sessions on startup
Asrc/lib/commentAuth.ts
@@ -0,0 +1,47 @@
1+import { db } from "../db/index.ts";
2+import type { SessionUser } from "../middleware/session.ts";
3+
4+/**
5+ * Authorise an edit on an issue or patch comment.
6+ *
7+ * Returns a Response if the request must be denied, or null if it may proceed.
8+ *
9+ * Verifies, in one query, that:
10+ * 1. the comment exists, and
11+ * 2. its parent issue/patch belongs to the requested repo (prevents
12+ * cross-repo bypasses where the URL repo and the comment's repo differ),
13+ * 3. the user is the comment author or an admin,
14+ * 4. the parent issue/patch is open (admins may edit on closed parents).
15+ */
16+export async function authorizeCommentEdit(
17+ kind: "issue" | "patch",
18+ commentId: number,
19+ repoId: number,
20+ user: SessionUser | null,
21+): Promise<Response | null> {
22+ if (!user) return new Response("Unauthorized", { status: 401 });
23+
24+ const row =
25+ kind === "issue"
26+ ? await db
27+ .selectFrom("issue_comments")
28+ .innerJoin("issues", "issues.id", "issue_comments.issue_id")
29+ .select(["issue_comments.author_id", "issues.status"])
30+ .where("issue_comments.id", "=", commentId)
31+ .where("issues.repo_id", "=", repoId)
32+ .executeTakeFirst()
33+ : await db
34+ .selectFrom("patch_comments")
35+ .innerJoin("patches", "patches.id", "patch_comments.patch_id")
36+ .select(["patch_comments.author_id", "patches.status"])
37+ .where("patch_comments.id", "=", commentId)
38+ .where("patches.repo_id", "=", repoId)
39+ .executeTakeFirst();
40+
41+ if (!row) return new Response("Not found", { status: 404 });
42+ if (row.author_id !== user.id && !user.isAdmin)
43+ return new Response("Forbidden", { status: 403 });
44+ if (row.status !== "open" && !user.isAdmin)
45+ return new Response("Forbidden", { status: 403 });
46+ return null;
47+}
Asrc/lib/pagination.ts
@@ -0,0 +1,15 @@
1+/**
2+ * Compute pagination state for a list view.
3+ *
4+ * Clamps `page` into [1, totalPages] so an out-of-range query string
5+ * (?page=999) lands on the last page instead of an empty offset.
6+ */
7+export function paginate(
8+ rawPage: number | undefined,
9+ totalCount: number,
10+ perPage: number,
11+): { page: number; totalPages: number; offset: number } {
12+ const totalPages = Math.max(1, Math.ceil(totalCount / perPage));
13+ const page = Math.min(Math.max(1, rawPage ?? 1), totalPages);
14+ return { page, totalPages, offset: (page - 1) * perPage };
15+}
Msrc/routes/ci.tsx
@@ -3,6 +3,7 @@ import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
55 import { db, getRepo } from "../db/index.ts";
6+import { paginate } from "../lib/pagination.ts";
67 import { requireAdmin, resolveSession } from "../middleware/session.ts";
78 import {
89 cancelRun,
@@ -87,19 +88,20 @@ export const ciRoutes = new Elysia()
8788 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
8889 if (!repo) return new Response("Not found", { status: 404 });
8990
90- const page = Math.max(1, query.page ?? 1);
91-
9291 const countRow = await db
9392 .selectFrom("ci_runs")
9493 .select(db.fn.countAll<number>().as("count"))
9594 .where("repo_id", "=", repo.id)
9695 .executeTakeFirst();
97- const totalPages = Math.max(
98- 1,
99- Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE),
96+ const {
97+ page: safePage,
98+ totalPages,
99+ offset,
100+ } = paginate(
101+ query.page,
102+ Number(countRow?.count ?? 0),
103+ CI_RUNS_PER_PAGE,
100104 );
101- const safePage = Math.min(page, totalPages);
102- const offset = (safePage - 1) * CI_RUNS_PER_PAGE;
103105
104106 const runs = await db
105107 .selectFrom("ci_runs")
Msrc/routes/issues.tsx
@@ -4,6 +4,8 @@ import config from "../config.ts";
44 import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
55 import { issuesLabelFilter } from "../db/helpers.ts";
66 import { db, getRepo, type LabelRow } from "../db/index.ts";
7+import { authorizeCommentEdit } from "../lib/commentAuth.ts";
8+import { paginate } from "../lib/pagination.ts";
79 import {
810 requireAdmin,
911 requireAuth,
@@ -33,7 +35,6 @@ export const issueRoutes = new Elysia()
3335 : query.status === "completed"
3436 ? ("completed" as const)
3537 : ("open" as const);
36- const page = Math.max(1, query.page ?? 1);
3738
3839 // Parse label filter: query.labels may be a string or array of strings
3940 const rawLabels = query.labels;
@@ -69,12 +70,11 @@ export const issueRoutes = new Elysia()
6970 const counts: Record<string, number> = Object.fromEntries(
7071 allCounts.map((r) => [r.status, Number(r.count)]),
7172 );
72- const totalPages = Math.max(
73- 1,
74- Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
75- );
76- const safePage = Math.min(page, totalPages);
77- const offset = (safePage - 1) * ISSUES_PER_PAGE;
73+ const {
74+ page: safePage,
75+ totalPages,
76+ offset,
77+ } = paginate(query.page, counts[status] ?? 0, ISSUES_PER_PAGE);
7878
7979 let listQuery = db
8080 .selectFrom("issues")
@@ -694,21 +694,13 @@ export const issueRoutes = new Elysia()
694694 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
695695 if (!repo) return new Response("Not found", { status: 404 });
696696
697- const comment = await db
698- .selectFrom("issue_comments")
699- .select(["id", "author_id", "issue_id"])
700- .where("id", "=", params.id)
701- .executeTakeFirst();
702- if (!comment) return new Response("Not found", { status: 404 });
703- if (comment.author_id !== user?.id && !user?.isAdmin)
704- return new Response("Forbidden", { status: 403 });
705- const parentIssue = await db
706- .selectFrom("issues")
707- .select("status")
708- .where("id", "=", comment.issue_id)
709- .executeTakeFirst();
710- if (parentIssue?.status !== "open" && !user?.isAdmin)
711- return new Response("Forbidden", { status: 403 });
697+ const denyComment = await authorizeCommentEdit(
698+ "issue",
699+ params.id,
700+ repo.id,
701+ user,
702+ );
703+ if (denyComment) return denyComment;
712704
713705 const issueNum = parseInt(params.number, 10);
714706 await db
@@ -717,7 +709,7 @@ export const issueRoutes = new Elysia()
717709 body: body.edit_body.trim(),
718710 edited_at: new Date().toISOString(),
719711 })
720- .where("id", "=", comment.id)
712+ .where("id", "=", params.id)
721713 .execute();
722714
723715 return new Response(null, {
Msrc/routes/patches.tsx
@@ -4,6 +4,8 @@ import config from "../config.ts";
44 import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
55 import { patchesLabelFilter } from "../db/helpers.ts";
66 import { db, getRepo, type LabelRow } from "../db/index.ts";
7+import { authorizeCommentEdit } from "../lib/commentAuth.ts";
8+import { paginate } from "../lib/pagination.ts";
79 import {
810 requireAdmin,
911 requireAuth,
@@ -61,7 +63,6 @@ export const patchRoutes = new Elysia()
6163 )
6264 ? query.status!
6365 : "open";
64- const page = Math.max(1, query.page ?? 1);
6566
6667 // Parse label filter
6768 const rawLabels = query.labels;
@@ -100,12 +101,11 @@ export const patchRoutes = new Elysia()
100101 const counts: Record<string, number> = Object.fromEntries(
101102 allCounts.map((r) => [r.status, Number(r.count)]),
102103 );
103- const totalPages = Math.max(
104- 1,
105- Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
106- );
107- const safePage = Math.min(page, totalPages);
108- const offset = (safePage - 1) * PATCHES_PER_PAGE;
104+ const {
105+ page: safePage,
106+ totalPages,
107+ offset,
108+ } = paginate(query.page, counts[status] ?? 0, PATCHES_PER_PAGE);
109109
110110 let listQuery = db
111111 .selectFrom("patches")
@@ -933,21 +933,13 @@ export const patchRoutes = new Elysia()
933933 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
934934 if (!repo) return new Response("Not found", { status: 404 });
935935
936- const comment = await db
937- .selectFrom("patch_comments")
938- .select(["id", "author_id", "patch_id"])
939- .where("id", "=", params.id)
940- .executeTakeFirst();
941- if (!comment) return new Response("Not found", { status: 404 });
942- if (comment.author_id !== user?.id && !user?.isAdmin)
943- return new Response("Forbidden", { status: 403 });
944- const parentPatch = await db
945- .selectFrom("patches")
946- .select("status")
947- .where("id", "=", comment.patch_id)
948- .executeTakeFirst();
949- if (parentPatch?.status !== "open" && !user?.isAdmin)
950- return new Response("Forbidden", { status: 403 });
936+ const denyComment = await authorizeCommentEdit(
937+ "patch",
938+ params.id,
939+ repo.id,
940+ user,
941+ );
942+ if (denyComment) return denyComment;
951943
952944 const patchNum = parseInt(params.number, 10);
953945 await db
@@ -956,7 +948,7 @@ export const patchRoutes = new Elysia()
956948 body: body.edit_body.trim(),
957949 edited_at: new Date().toISOString(),
958950 })
959- .where("id", "=", comment.id)
951+ .where("id", "=", params.id)
960952 .execute();
961953
962954 return new Response(null, {
Msrc/routes/releases.tsx
@@ -4,6 +4,7 @@ import { Elysia, t } from "elysia";
44 import config from "../config.ts";
55 import { paths, RELEASES_PER_PAGE } from "../constants.ts";
66 import { db, getRepo } from "../db/index.ts";
7+import { paginate } from "../lib/pagination.ts";
78 import { requireAdmin, resolveSession } from "../middleware/session.ts";
89 import { archiveRepo, git } from "../services/git.ts";
910 import { renderMarkdown } from "../services/markdown.ts";
@@ -35,19 +36,20 @@ export const releasesRoutes = new Elysia()
3536 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
3637 if (!repo) return new Response("Not found", { status: 404 });
3738
38- const page = Math.max(1, query.page ?? 1);
39-
4039 const countRow = await db
4140 .selectFrom("releases")
4241 .select(db.fn.countAll<number>().as("count"))
4342 .where("repo_id", "=", repo.id)
4443 .executeTakeFirst();
45- const totalPages = Math.max(
46- 1,
47- Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE),
44+ const {
45+ page: safePage,
46+ totalPages,
47+ offset,
48+ } = paginate(
49+ query.page,
50+ Number(countRow?.count ?? 0),
51+ RELEASES_PER_PAGE,
4852 );
49- const safePage = Math.min(page, totalPages);
50- const offset = (safePage - 1) * RELEASES_PER_PAGE;
5153
5254 const releasesRaw = await db
5355 .selectFrom("releases")
Msrc/services/ci.ts
@@ -1007,13 +1007,34 @@ export async function triggerRun(
10071007 runningTasks.set(runId.id, { controller });
10081008
10091009 // Fire and forget — like release archiving
1010- (async () => {
1011- await executeRun(runId.id, controller.signal);
1012- })();
1010+ spawnRun(runId.id, controller.signal);
10131011
10141012 return runId.id;
10151013 }
10161014
1015+// Wraps the fire-and-forget executeRun so unhandled exceptions (e.g. a throw
1016+// before/after its own try/finally) are logged and the run is reconciled to
1017+// "failure" instead of staying pending forever.
1018+function spawnRun(runId: number, signal: AbortSignal): void {
1019+ void executeRun(runId, signal).catch(async (err) => {
1020+ console.error(`[ci] executeRun threw for run ${runId}:`, err);
1021+ runningTasks.delete(runId);
1022+ try {
1023+ await db
1024+ .updateTable("ci_runs")
1025+ .set({
1026+ status: "failure",
1027+ finished_at: new Date().toISOString(),
1028+ })
1029+ .where("id", "=", runId)
1030+ .where("status", "in", ["pending", "running"])
1031+ .execute();
1032+ } catch (dbErr) {
1033+ console.error(`[ci] failed to mark run ${runId} failed:`, dbErr);
1034+ }
1035+ });
1036+}
1037+
10171038 export async function retryRun(
10181039 runId: number,
10191040 retriedBy: number,
@@ -1050,9 +1071,7 @@ export async function retryRun(
10501071 const controller = new AbortController();
10511072 runningTasks.set(runId, { controller });
10521073
1053- (async () => {
1054- await executeRun(runId, controller.signal);
1055- })();
1074+ spawnRun(runId, controller.signal);
10561075 }
10571076
10581077 export async function cancelRun(runId: number): Promise<void> {
Msrc/services/markdown.ts
@@ -194,9 +194,9 @@ export function plaintextPreview(
194194 if (firstLine.length <= maxLen) return firstLine;
195195 const truncated = firstLine.slice(0, maxLen);
196196 const lastSpace = truncated.lastIndexOf(" ");
197- return (
198- (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
197+ return `${
198+ lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
199199 ? truncated.slice(0, lastSpace)
200- : truncated) + "…"
201- );
200+ : truncated
201+ }…`;
202202 }
Msrc/services/patchCache.ts
@@ -1,15 +1,24 @@
1-import { MAX_PATCH_CACHE } from "../constants.ts";
1+import { MAX_PATCH_CACHE, PATCH_CACHE_TTL_MS } from "../constants.ts";
22
33 export type ApplyResult = { status: "clean" | "conflict"; output: string };
4-const cache = new Map<number, ApplyResult>();
4+type Entry = { result: ApplyResult; expiresAt: number };
5+const cache = new Map<number, Entry>();
56
67 export const patchCache = {
7- get: (id: number): ApplyResult | undefined => cache.get(id),
8+ get: (id: number): ApplyResult | undefined => {
9+ const entry = cache.get(id);
10+ if (!entry) return undefined;
11+ if (entry.expiresAt <= Date.now()) {
12+ cache.delete(id);
13+ return undefined;
14+ }
15+ return entry.result;
16+ },
817 set: (id: number, result: ApplyResult) => {
9- if (cache.size >= MAX_PATCH_CACHE) {
18+ if (cache.size >= MAX_PATCH_CACHE && !cache.has(id)) {
1019 cache.delete(cache.keys().next().value!);
1120 }
12- cache.set(id, result);
21+ cache.set(id, { result, expiresAt: Date.now() + PATCH_CACHE_TTL_MS });
1322 },
1423 invalidate: (id: number) => cache.delete(id),
1524 };
Asrc/views/CommentThread.tsx
@@ -0,0 +1,134 @@
1+import config from "../config.ts";
2+import { displayName } from "../lib/users.ts";
3+import type { SessionUser } from "../middleware/session.ts";
4+import { Avatar } from "./Avatar.tsx";
5+import { DateWithEdited } from "./DateWithEdited.tsx";
6+import { ReactionBar, type ReactionCount } from "./ReactionBar.tsx";
7+
8+export interface ThreadComment {
9+ id: number;
10+ author_id: number | null;
11+ author_username: string;
12+ author_avatar_version: number | null;
13+ body: string;
14+ bodyHtml: string;
15+ created_at: string;
16+ edited_at: string | null;
17+}
18+
19+interface CommentThreadProps {
20+ user: SessionUser | null;
21+ comments: ThreadComment[];
22+ commentReactions: Map<number, ReactionCount[]>;
23+ baseUrl: string;
24+ parentStatus: string;
25+}
26+
27+export function CommentThread({
28+ user,
29+ comments,
30+ commentReactions,
31+ baseUrl,
32+ parentStatus,
33+}: CommentThreadProps) {
34+ const reactUrl = `${baseUrl}/react`;
35+ const canEditComment = (c: ThreadComment) =>
36+ user != null &&
37+ (user.isAdmin || (user.id === c.author_id && parentStatus === "open"));
38+ return (
39+ <>
40+ {comments.map((comment) => (
41+ <div class="timeline-item">
42+ <div class="timeline-author">
43+ <Avatar
44+ userId={comment.author_id}
45+ version={comment.author_avatar_version}
46+ size={24}
47+ />
48+ <strong>{displayName(comment.author_username)}</strong>
49+ <div class="timeline-author-right">
50+ {canEditComment(comment) && (
51+ <details class="inline-edit-details">
52+ <summary class="btn btn-xs">
53+ <span class="when-closed">Edit</span>
54+ <span class="when-open">
55+ Stop editing
56+ </span>
57+ </summary>
58+ </details>
59+ )}
60+ <DateWithEdited
61+ date={comment.created_at}
62+ editedAt={comment.edited_at}
63+ />
64+ </div>
65+ </div>
66+ {canEditComment(comment) && (
67+ <div class="inline-edit-form-area">
68+ <form
69+ method="POST"
70+ action={`${baseUrl}/comments/${comment.id}/edit`}
71+ class="inline-edit-form"
72+ >
73+ <div class="form-group">
74+ <textarea
75+ class="form-input"
76+ name="edit_body"
77+ rows="6"
78+ maxlength={config.MAX_TEXT_BODY_BYTES}
79+ >
80+ {comment.body}
81+ </textarea>
82+ </div>
83+ <div class="form-actions">
84+ <button
85+ type="submit"
86+ class="btn btn-sm btn-primary"
87+ >
88+ Save
89+ </button>
90+ </div>
91+ </form>
92+ </div>
93+ )}
94+ <div class="timeline-body markdown-body">
95+ {comment.bodyHtml}
96+ </div>
97+ <ReactionBar
98+ reactions={commentReactions.get(comment.id) ?? []}
99+ postUrl={reactUrl}
100+ commentId={comment.id}
101+ user={user}
102+ />
103+ </div>
104+ ))}
105+
106+ {user && (
107+ <div class="timeline-item timeline-item-new">
108+ <h3 class="section-title">Add a comment</h3>
109+ <form method="POST" action={`${baseUrl}/comments`}>
110+ <div class="form-group">
111+ <textarea
112+ name="body"
113+ rows="6"
114+ maxlength={config.MAX_TEXT_BODY_BYTES}
115+ placeholder="Leave a comment (Markdown supported)"
116+ required
117+ />
118+ </div>
119+ <div class="form-actions">
120+ <button type="submit" class="btn btn-primary">
121+ Comment
122+ </button>
123+ </div>
124+ </form>
125+ </div>
126+ )}
127+ {!user && (
128+ <p class="text-muted">
129+ <a href="/login">Sign in</a> to leave a comment.
130+ </p>
131+ )}
132+ </>
133+ );
134+}
Asrc/views/EditableTitle.tsx
@@ -0,0 +1,76 @@
1+import config from "../config.ts";
2+
3+interface EditableTitleProps {
4+ title: string;
5+ canEdit: boolean;
6+ editAction: string;
7+ bodyFieldName: string;
8+ bodyValue: string;
9+}
10+
11+export function EditableTitle({
12+ title,
13+ canEdit,
14+ editAction,
15+ bodyFieldName,
16+ bodyValue,
17+}: EditableTitleProps) {
18+ return (
19+ <>
20+ {canEdit && (
21+ <input
22+ type="checkbox"
23+ id="title-edit-toggle"
24+ class="title-edit-toggle"
25+ />
26+ )}
27+ <div class="title-with-edit">
28+ <h2 class="issue-detail-title">{title}</h2>
29+ {canEdit && (
30+ <>
31+ <div class="title-edit-form-area">
32+ <form
33+ method="POST"
34+ action={editAction}
35+ class="title-edit-form"
36+ >
37+ <input
38+ class="form-input"
39+ name="title"
40+ value={title}
41+ required
42+ maxlength={config.MAX_TITLE_BYTES}
43+ />
44+ <input
45+ type="hidden"
46+ name={bodyFieldName}
47+ value={bodyValue}
48+ />
49+ <div class="title-edit-actions">
50+ <button
51+ type="submit"
52+ class="btn btn-sm btn-primary"
53+ >
54+ Save
55+ </button>
56+ <label
57+ for="title-edit-toggle"
58+ class="btn btn-sm"
59+ >
60+ Cancel
61+ </label>
62+ </div>
63+ </form>
64+ </div>
65+ <label
66+ for="title-edit-toggle"
67+ class="btn btn-xs title-edit-open"
68+ >
69+ Edit title
70+ </label>
71+ </>
72+ )}
73+ </div>
74+ </>
75+ );
76+}
Asrc/views/LabelBadges.tsx
@@ -0,0 +1,73 @@
1+import type { LabelRow } from "../db/index.ts";
2+import { labelTextColor } from "../lib/labelColor.ts";
3+
4+interface LabelBadgesProps {
5+ labels: LabelRow[];
6+ repoLabels: LabelRow[];
7+ canManage: boolean;
8+ baseUrl: string;
9+}
10+
11+export function LabelBadges({
12+ labels,
13+ repoLabels,
14+ canManage,
15+ baseUrl,
16+}: LabelBadgesProps) {
17+ if (labels.length === 0 && !canManage) return null;
18+ const available = repoLabels.filter(
19+ (l) => !labels.some((al) => al.id === l.id),
20+ );
21+ return (
22+ <div class="issue-labels-row">
23+ {labels.map((label) => (
24+ <span class="label-badge-wrap">
25+ <span
26+ class="label-badge"
27+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
28+ >
29+ {label.name}
30+ </span>
31+ {canManage && (
32+ <form
33+ method="POST"
34+ action={`${baseUrl}/labels/remove`}
35+ class="label-remove-form"
36+ >
37+ <input
38+ type="hidden"
39+ name="label_id"
40+ value={String(label.id)}
41+ />
42+ <button
43+ type="submit"
44+ class="label-remove-btn"
45+ title="Remove label"
46+ >
47+ ×
48+ </button>
49+ </form>
50+ )}
51+ </span>
52+ ))}
53+ {canManage && available.length > 0 && (
54+ <form
55+ method="POST"
56+ action={`${baseUrl}/labels/add`}
57+ class="label-add-inline-form"
58+ >
59+ <select name="label_id" class="label-select">
60+ {available.map((label) => (
61+ <option value={String(label.id)}>
62+ {label.name}
63+ </option>
64+ ))}
65+ </select>
66+ <button type="submit" class="btn btn-sm btn-secondary">
67+ Add label
68+ </button>
69+ </form>
70+ )}
71+ </div>
72+ );
73+}
Msrc/views/Settings.tsx
@@ -541,10 +541,7 @@ export function Settings({
541541 )}
542542 </div>
543543
544- <script
545- type="module"
546- src="/assets/passkey-settings.js"
547- ></script>
544+ <script type="module" src="/assets/passkey-settings.js"></script>
548545 </Layout>
549546 );
550547 }
Msrc/views/auth/Login.tsx
@@ -52,10 +52,7 @@ export function Login({ error }: LoginProps) {
5252 Don't have an account? <a href="/register">Register</a>
5353 </p>
5454 </div>
55- <script
56- type="module"
57- src="/assets/passkey-login.js"
58- ></script>
55+ <script type="module" src="/assets/passkey-login.js"></script>
5956 </Layout>
6057 );
6158 }
Msrc/views/auth/Register.tsx
@@ -99,10 +99,7 @@ export function Register({ error, question, pending }: RegisterProps) {
9999 Already have an account? <a href="/login">Sign in</a>
100100 </p>
101101 </div>
102- <script
103- type="module"
104- src="/assets/passkey-register.js"
105- ></script>
102+ <script type="module" src="/assets/passkey-register.js"></script>
106103 </Layout>
107104 );
108105 }
Msrc/views/issues/IssueDetail.tsx
@@ -5,11 +5,13 @@ import type {
55 LabelRow,
66 RepositoryRow,
77 } from "../../db/index.ts";
8-import { labelTextColor } from "../../lib/labelColor.ts";
98 import { displayName } from "../../lib/users.ts";
109 import type { SessionUser } from "../../middleware/session.ts";
1110 import { Avatar } from "../Avatar.tsx";
11+import { CommentThread } from "../CommentThread.tsx";
1212 import { DateWithEdited } from "../DateWithEdited.tsx";
13+import { EditableTitle } from "../EditableTitle.tsx";
14+import { LabelBadges } from "../LabelBadges.tsx";
1315 import { Layout } from "../layout.tsx";
1416 import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
1517 import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -45,6 +47,7 @@ export function IssueDetail({
4547 issueLabels,
4648 repoLabels,
4749 }: IssueDetailProps) {
50+ const baseUrl = `/${repo.name}/issues/${issue.number}`;
4851 const canEditIssue =
4952 user != null &&
5053 (user.isAdmin ||
@@ -53,9 +56,6 @@ export function IssueDetail({
5356 user != null &&
5457 (user.isAdmin ||
5558 (repo.allow_user_labels === 1 && user.id === issue.author_id));
56- const canEditComment = (c: IssueCommentRow) =>
57- user != null &&
58- (user.isAdmin || (user.id === c.author_id && issue.status === "open"));
5959 return (
6060 <Layout user={user} title={`${issue.title} — ${repo.name}`}>
6161 <div class="container">
@@ -64,62 +64,13 @@ export function IssueDetail({
6464 <div class="issue-detail">
6565 <div class="issue-detail-header">
6666 <span class="issue-number">#{issue.number}</span>
67- {canEditIssue && (
68- <input
69- type="checkbox"
70- id="title-edit-toggle"
71- class="title-edit-toggle"
72- />
73- )}
74- <div class="title-with-edit">
75- <h2 class="issue-detail-title">{issue.title}</h2>
76- {canEditIssue && (
77- <>
78- <div class="title-edit-form-area">
79- <form
80- method="POST"
81- action={`/${repo.name}/issues/${issue.number}/edit`}
82- class="title-edit-form"
83- >
84- <input
85- class="form-input"
86- name="title"
87- value={issue.title}
88- required
89- maxlength={
90- config.MAX_TITLE_BYTES
91- }
92- />
93- <input
94- type="hidden"
95- name="edit_body"
96- value={issue.body}
97- />
98- <div class="title-edit-actions">
99- <button
100- type="submit"
101- class="btn btn-sm btn-primary"
102- >
103- Save
104- </button>
105- <label
106- for="title-edit-toggle"
107- class="btn btn-sm"
108- >
109- Cancel
110- </label>
111- </div>
112- </form>
113- </div>
114- <label
115- for="title-edit-toggle"
116- class="btn btn-xs title-edit-open"
117- >
118- Edit title
119- </label>
120- </>
121- )}
122- </div>
67+ <EditableTitle
68+ title={issue.title}
69+ canEdit={canEditIssue}
70+ editAction={`${baseUrl}/edit`}
71+ bodyFieldName="edit_body"
72+ bodyValue={issue.body}
73+ />
12374 <span class={`issue-badge ${issue.status}`}>
12475 {issue.status}
12576 </span>
@@ -128,7 +79,7 @@ export function IssueDetail({
12879 {user?.isAdmin && issue.status === "open" && (
12980 <form
13081 method="POST"
131- action={`/${repo.name}/issues/${issue.number}/complete`}
82+ action={`${baseUrl}/complete`}
13283 class="inline-form"
13384 >
13485 <button
@@ -142,7 +93,7 @@ export function IssueDetail({
14293 {user?.isAdmin && (
14394 <form
14495 method="POST"
145- action={`/${repo.name}/issues/${issue.number}/close`}
96+ action={`${baseUrl}/close`}
14697 class="inline-form"
14798 >
14899 <button
@@ -163,7 +114,7 @@ export function IssueDetail({
163114 Permanently delete this issue?
164115 <form
165116 method="POST"
166- action={`/${repo.name}/issues/${issue.number}/delete`}
117+ action={`${baseUrl}/delete`}
167118 class="inline-form"
168119 >
169120 <button
@@ -179,80 +130,12 @@ export function IssueDetail({
179130 )}
180131 </div>
181132
182- {(issueLabels.length > 0 || canManageLabels) && (
183- <div class="issue-labels-row">
184- {issueLabels.map((label) => (
185- <span class="label-badge-wrap">
186- <span
187- class="label-badge"
188- style={`background:${label.color};color:${labelTextColor(label.color)}`}
189- >
190- {label.name}
191- </span>
192- {canManageLabels && (
193- <form
194- method="POST"
195- action={`/${repo.name}/issues/${issue.number}/labels/remove`}
196- class="label-remove-form"
197- >
198- <input
199- type="hidden"
200- name="label_id"
201- value={String(label.id)}
202- />
203- <button
204- type="submit"
205- class="label-remove-btn"
206- title="Remove label"
207- >
208- ×
209- </button>
210- </form>
211- )}
212- </span>
213- ))}
214- {canManageLabels &&
215- repoLabels.filter(
216- (l) =>
217- !issueLabels.some(
218- (il) => il.id === l.id,
219- ),
220- ).length > 0 && (
221- <form
222- method="POST"
223- action={`/${repo.name}/issues/${issue.number}/labels/add`}
224- class="label-add-inline-form"
225- >
226- <select
227- name="label_id"
228- class="label-select"
229- >
230- {repoLabels
231- .filter(
232- (l) =>
233- !issueLabels.some(
234- (il) =>
235- il.id === l.id,
236- ),
237- )
238- .map((label) => (
239- <option
240- value={String(label.id)}
241- >
242- {label.name}
243- </option>
244- ))}
245- </select>
246- <button
247- type="submit"
248- class="btn btn-sm btn-secondary"
249- >
250- Add label
251- </button>
252- </form>
253- )}
254- </div>
255- )}
133+ <LabelBadges
134+ labels={issueLabels}
135+ repoLabels={repoLabels}
136+ canManage={canManageLabels}
137+ baseUrl={baseUrl}
138+ />
256139
257140 <div class="timeline-item">
258141 <div class="timeline-author">
@@ -287,7 +170,7 @@ export function IssueDetail({
287170 <div class="inline-edit-form-area">
288171 <form
289172 method="POST"
290- action={`/${repo.name}/issues/${issue.number}/edit`}
173+ action={`${baseUrl}/edit`}
291174 class="inline-edit-form"
292175 >
293176 <input
@@ -328,117 +211,18 @@ export function IssueDetail({
328211 </div>
329212 <ReactionBar
330213 reactions={reactions}
331- postUrl={`/${repo.name}/issues/${issue.number}/react`}
214+ postUrl={`${baseUrl}/react`}
332215 user={user}
333216 />
334217 </div>
335218
336- {comments.map((comment) => (
337- <div class="timeline-item">
338- <div class="timeline-author">
339- <Avatar
340- userId={comment.author_id}
341- version={comment.author_avatar_version}
342- size={24}
343- />
344- <strong>
345- {displayName(comment.author_username)}
346- </strong>
347- <div class="timeline-author-right">
348- {canEditComment(comment) && (
349- <details class="inline-edit-details">
350- <summary class="btn btn-xs">
351- <span class="when-closed">
352- Edit
353- </span>
354- <span class="when-open">
355- Stop editing
356- </span>
357- </summary>
358- </details>
359- )}
360- <DateWithEdited
361- date={comment.created_at}
362- editedAt={comment.edited_at}
363- />
364- </div>
365- </div>
366- {canEditComment(comment) && (
367- <div class="inline-edit-form-area">
368- <form
369- method="POST"
370- action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`}
371- class="inline-edit-form"
372- >
373- <div class="form-group">
374- <textarea
375- class="form-input"
376- name="edit_body"
377- rows="6"
378- maxlength={
379- config.MAX_TEXT_BODY_BYTES
380- }
381- >
382- {comment.body}
383- </textarea>
384- </div>
385- <div class="form-actions">
386- <button
387- type="submit"
388- class="btn btn-sm btn-primary"
389- >
390- Save
391- </button>
392- </div>
393- </form>
394- </div>
395- )}
396- <div class="timeline-body markdown-body">
397- {comment.bodyHtml}
398- </div>
399- <ReactionBar
400- reactions={
401- commentReactions.get(comment.id) ?? []
402- }
403- postUrl={`/${repo.name}/issues/${issue.number}/react`}
404- commentId={comment.id}
405- user={user}
406- />
407- </div>
408- ))}
409-
410- {user && (
411- <div class="timeline-item timeline-item-new">
412- <h3 class="section-title">Add a comment</h3>
413- <form
414- method="POST"
415- action={`/${repo.name}/issues/${issue.number}/comments`}
416- >
417- <div class="form-group">
418- <textarea
419- name="body"
420- rows="6"
421- maxlength={config.MAX_TEXT_BODY_BYTES}
422- placeholder="Leave a comment (Markdown supported)"
423- required
424- />
425- </div>
426- <div class="form-actions">
427- <button
428- type="submit"
429- class="btn btn-primary"
430- >
431- Comment
432- </button>
433- </div>
434- </form>
435- </div>
436- )}
437- {!user && (
438- <p class="text-muted">
439- <a href="/login">Sign in</a> to leave a comment.
440- </p>
441- )}
219+ <CommentThread
220+ user={user}
221+ comments={comments}
222+ commentReactions={commentReactions}
223+ baseUrl={baseUrl}
224+ parentStatus={issue.status}
225+ />
442226 </div>
443227 </div>
444228 </Layout>
Msrc/views/patches/PatchDetail.tsx
@@ -7,15 +7,17 @@ import type {
77 RepositoryRow,
88 } from "../../db/index.ts";
99 import { formatDateTime } from "../../lib/formatDate.ts";
10-import { labelTextColor } from "../../lib/labelColor.ts";
1110 import { displayName } from "../../lib/users.ts";
1211 import type { SessionUser } from "../../middleware/session.ts";
1312 import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
1413 import type { PatchMeta } from "../../services/git.ts";
1514 import type { ApplyResult } from "../../services/patchCache.ts";
1615 import { Avatar } from "../Avatar.tsx";
16+import { CommentThread } from "../CommentThread.tsx";
1717 import { DateWithEdited } from "../DateWithEdited.tsx";
1818 import { DiffView } from "../DiffView.tsx";
19+import { EditableTitle } from "../EditableTitle.tsx";
20+import { LabelBadges } from "../LabelBadges.tsx";
1921 import { Layout } from "../layout.tsx";
2022 import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
2123 import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -69,9 +71,6 @@ export function PatchDetail({
6971 user != null &&
7072 (user.isAdmin ||
7173 (repo.allow_user_labels === 1 && user.id === patch.author_id));
72- const canEditComment = (c: PatchCommentRow) =>
73- user != null &&
74- (user.isAdmin || (user.id === c.author_id && patch.status === "open"));
7574
7675 const baseUrl = `/${repo.name}/patches/${patch.number}`;
7776 const reactUrl = `${baseUrl}/react`;
@@ -85,62 +84,13 @@ export function PatchDetail({
8584 <div class="issue-detail">
8685 <div class="issue-detail-header">
8786 <span class="issue-number">#{patch.number}</span>
88- {canEdit && (
89- <input
90- type="checkbox"
91- id="title-edit-toggle"
92- class="title-edit-toggle"
93- />
94- )}
95- <div class="title-with-edit">
96- <h2 class="issue-detail-title">{patch.title}</h2>
97- {canEdit && (
98- <>
99- <div class="title-edit-form-area">
100- <form
101- method="POST"
102- action={`${baseUrl}/edit`}
103- class="title-edit-form"
104- >
105- <input
106- class="form-input"
107- name="title"
108- value={patch.title}
109- required
110- maxlength={
111- config.MAX_TITLE_BYTES
112- }
113- />
114- <input
115- type="hidden"
116- name="edit_description"
117- value={patch.description}
118- />
119- <div class="title-edit-actions">
120- <button
121- type="submit"
122- class="btn btn-sm btn-primary"
123- >
124- Save
125- </button>
126- <label
127- for="title-edit-toggle"
128- class="btn btn-sm"
129- >
130- Cancel
131- </label>
132- </div>
133- </form>
134- </div>
135- <label
136- for="title-edit-toggle"
137- class="btn btn-xs title-edit-open"
138- >
139- Edit title
140- </label>
141- </>
142- )}
143- </div>
87+ <EditableTitle
88+ title={patch.title}
89+ canEdit={canEdit}
90+ editAction={`${baseUrl}/edit`}
91+ bodyFieldName="edit_description"
92+ bodyValue={patch.description}
93+ />
14494 <span class={`patch-badge ${patch.status}`}>
14595 {patch.status}
14696 </span>
@@ -235,80 +185,12 @@ export function PatchDetail({
235185 </div>
236186 )}
237187 </div>
238- {(patchLabels.length > 0 || canManageLabels) && (
239- <div class="issue-labels-row">
240- {patchLabels.map((label) => (
241- <span class="label-badge-wrap">
242- <span
243- class="label-badge"
244- style={`background:${label.color};color:${labelTextColor(label.color)}`}
245- >
246- {label.name}
247- </span>
248- {canManageLabels && (
249- <form
250- method="POST"
251- action={`${baseUrl}/labels/remove`}
252- class="label-remove-form"
253- >
254- <input
255- type="hidden"
256- name="label_id"
257- value={String(label.id)}
258- />
259- <button
260- type="submit"
261- class="label-remove-btn"
262- title="Remove label"
263- >
264- ×
265- </button>
266- </form>
267- )}
268- </span>
269- ))}
270- {canManageLabels &&
271- repoLabels.filter(
272- (l) =>
273- !patchLabels.some(
274- (pl) => pl.id === l.id,
275- ),
276- ).length > 0 && (
277- <form
278- method="POST"
279- action={`${baseUrl}/labels/add`}
280- class="label-add-inline-form"
281- >
282- <select
283- name="label_id"
284- class="label-select"
285- >
286- {repoLabels
287- .filter(
288- (l) =>
289- !patchLabels.some(
290- (pl) =>
291- pl.id === l.id,
292- ),
293- )
294- .map((label) => (
295- <option
296- value={String(label.id)}
297- >
298- {label.name}
299- </option>
300- ))}
301- </select>
302- <button
303- type="submit"
304- class="btn btn-sm btn-secondary"
305- >
306- Add label
307- </button>
308- </form>
309- )}
310- </div>
311- )}
188+ <LabelBadges
189+ labels={patchLabels}
190+ repoLabels={repoLabels}
191+ canManage={canManageLabels}
192+ baseUrl={baseUrl}
193+ />
312194 </div>
313195
314196 {/* Subview tabs */}
@@ -442,115 +324,13 @@ export function PatchDetail({
442324 </div>
443325 )}
444326
445- {/* Comments */}
446- {comments.map((comment) => (
447- <div class="timeline-item">
448- <div class="timeline-author">
449- <Avatar
450- userId={comment.author_id}
451- version={comment.author_avatar_version}
452- size={24}
453- />
454- <strong>
455- {displayName(comment.author_username)}
456- </strong>
457- <div class="timeline-author-right">
458- {canEditComment(comment) && (
459- <details class="inline-edit-details">
460- <summary class="btn btn-xs">
461- <span class="when-closed">
462- Edit
463- </span>
464- <span class="when-open">
465- Stop editing
466- </span>
467- </summary>
468- </details>
469- )}
470- <DateWithEdited
471- date={comment.created_at}
472- editedAt={comment.edited_at}
473- />
474- </div>
475- </div>
476- {canEditComment(comment) && (
477- <div class="inline-edit-form-area">
478- <form
479- method="POST"
480- action={`${baseUrl}/comments/${comment.id}/edit`}
481- class="inline-edit-form"
482- >
483- <div class="form-group">
484- <textarea
485- class="form-input"
486- name="edit_body"
487- rows="6"
488- maxlength={
489- config.MAX_TEXT_BODY_BYTES
490- }
491- >
492- {comment.body}
493- </textarea>
494- </div>
495- <div class="form-actions">
496- <button
497- type="submit"
498- class="btn btn-sm btn-primary"
499- >
500- Save
501- </button>
502- </div>
503- </form>
504- </div>
505- )}
506- <div class="timeline-body markdown-body">
507- {comment.bodyHtml}
508- </div>
509- <ReactionBar
510- reactions={
511- commentReactions.get(comment.id) ?? []
512- }
513- postUrl={reactUrl}
514- commentId={comment.id}
515- user={user}
516- />
517- </div>
518- ))}
519-
520- {user && (
521- <div class="timeline-item timeline-item-new">
522- <h3 class="section-title">Add a comment</h3>
523- <form
524- method="POST"
525- action={`${baseUrl}/comments`}
526- >
527- <div class="form-group">
528- <textarea
529- name="body"
530- rows="6"
531- maxlength={
532- config.MAX_TEXT_BODY_BYTES
533- }
534- placeholder="Leave a comment (Markdown supported)"
535- required
536- />
537- </div>
538- <div class="form-actions">
539- <button
540- type="submit"
541- class="btn btn-primary"
542- >
543- Comment
544- </button>
545- </div>
546- </form>
547- </div>
548- )}
549- {!user && (
550- <p class="text-muted">
551- <a href="/login">Sign in</a> to leave a comment.
552- </p>
553- )}
327+ <CommentThread
328+ user={user}
329+ comments={comments}
330+ commentReactions={commentReactions}
331+ baseUrl={baseUrl}
332+ parentStatus={patch.status}
333+ />
554334 </div>
555335 ) : (
556336 <div>
Msrc/views/repos/BranchSelector.tsx
@@ -32,11 +32,7 @@ export function BranchSelector({
3232 <input type="hidden" name="view" value={view} />
3333 {path && <input type="hidden" name="path" value={path} />}
3434 <span class="branch-selector-icon">⎇</span>
35- <select
36- name="rev"
37- class="branch-select"
38- data-autosubmit
39- >
35+ <select name="rev" class="branch-select" data-autosubmit>
4036 {isDetached && (
4137 <option value={currentRef} selected>
4238 {shortRef} (detached)
Msrc/views/repos/FileBlob.tsx
@@ -133,7 +133,7 @@ export function FileBlob({
133133 ) : view.mimeType.startsWith("audio/") ? (
134134 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
135135 <audio
136- controls
136+ controls=""
137137 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
138138 class="file-media-audio"
139139 />
Mtests/e2e.auth.test.ts
@@ -99,6 +99,20 @@ describe('auth', () => {
9999 } finally { await ctx.close(); }
100100 });
101101
102+ test('cross-origin POST is rejected (CSRF defense)', async () => {
103+ const ctx = await browser.newContext();
104+ try {
105+ // Forge an Origin from a different host; server should refuse the POST.
106+ const resp = await ctx.request.post(`${BASE}/login`, {
107+ headers: { Origin: 'http://evil.example' },
108+ form: { username: 'admin', password: ADMIN_PASS },
109+ maxRedirects: 0,
110+ });
111+ expect(resp.status()).toBe(403);
112+ // Other tests (login, register) cover the same-origin success path.
113+ } finally { await ctx.close(); }
114+ });
115+
102116 test('logout clears session', async () => {
103117 const ctx = await browser.newContext();
104118 const page = await ctx.newPage();
Mtests/e2e.issues.test.ts
@@ -340,6 +340,42 @@ describe('issue editing', () => {
340340 } finally { await page.close(); }
341341 });
342342
343+ test('cannot edit comment via wrong repo url (cross-repo bypass)', async () => {
344+ // Create a second repo
345+ const setupPage = await adminCtx.newPage();
346+ try {
347+ await setupPage.goto(`${BASE}/new`);
348+ await setupPage.fill('[name=name]', 'other-repo');
349+ await setupPage.click('form[action="/new"] button[type=submit]');
350+ await setupPage.waitForURL(`${BASE}/other-repo`);
351+ } finally { await setupPage.close(); }
352+
353+ // Pull a comment id from the existing my-repo issue
354+ const issueNum = issueUrl.split('/issues/')[1];
355+ const page = await adminCtx.newPage();
356+ try {
357+ await page.goto(issueUrl);
358+ const formAction = await page
359+ .locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`)
360+ .first()
361+ .getAttribute('action');
362+ expect(formAction).toBeTruthy();
363+ const commentId = formAction!.split('/comments/')[1]!.split('/')[0];
364+
365+ // Edit the same comment via /other-repo/... — must 404, not 200/302
366+ const resp = await page.request.post(
367+ `${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`,
368+ { form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 },
369+ );
370+ expect(resp.status()).toBe(404);
371+
372+ // And the original comment must be unchanged
373+ await page.goto(issueUrl);
374+ const bodies = await page.locator('.timeline-body').allTextContents();
375+ expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).toBe(true);
376+ } finally { await page.close(); }
377+ });
378+
343379 test('admin can delete issue', async () => {
344380 const issueNum = issueUrl.split('/issues/')[1];
345381 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {