issue/patch labels

AuthorKonata <konata@posteo.jp>
Date
Commit64ce9a25dd581bd82441125aecfd0973399daeb3
Parent927c263
14 files changed, 1454 insertions(+), 129 deletions(-)
MREADME.md
@@ -95,5 +95,4 @@ bun run test # Run E2E and unit tests (don't use bun test directly, it
9595
9696 ## Roadmap
9797 - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
98-- Issue labels
9998 - more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion
Msrc/db/index.ts
@@ -136,6 +136,24 @@ interface ReleaseAssetTable {
136136 created_at: string;
137137 }
138138
139+interface LabelTable {
140+ id: Generated<number>;
141+ repo_id: number;
142+ name: string;
143+ color: string;
144+ created_at: string;
145+}
146+
147+interface IssueLabelTable {
148+ issue_id: number;
149+ label_id: number;
150+}
151+
152+interface PatchLabelTable {
153+ patch_id: number;
154+ label_id: number;
155+}
156+
139157 export interface Database {
140158 users: UserTable;
141159 passkeys: PasskeyTable;
@@ -150,6 +168,9 @@ export interface Database {
150168 ssh_keys: SshKeyTable;
151169 releases: ReleaseTable;
152170 release_assets: ReleaseAssetTable;
171+ labels: LabelTable;
172+ issue_labels: IssueLabelTable;
173+ patch_labels: PatchLabelTable;
153174 }
154175
155176 // Selectable row types (id is plain number, as returned by queries)
@@ -166,6 +187,7 @@ export type PatchReactionRow = Selectable<PatchReactionTable>;
166187 export type SshKeyRow = Selectable<SshKeyTable>;
167188 export type ReleaseRow = Selectable<ReleaseTable>;
168189 export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
190+export type LabelRow = Selectable<LabelTable>;
169191
170192 const sqlite = new BunDatabase(DB_PATH);
171193 sqlite.run("PRAGMA journal_mode=WAL");
@@ -201,6 +223,26 @@ export const db = new Kysely<Database>({
201223 dialect: new BunSqliteDialect({ database: sqlite }),
202224 });
203225
226+// Migration: create labels tables if missing
227+sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
228+ id INTEGER PRIMARY KEY AUTOINCREMENT,
229+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
230+ name TEXT NOT NULL,
231+ color TEXT NOT NULL DEFAULT '#808080',
232+ created_at TEXT NOT NULL,
233+ UNIQUE(repo_id, name)
234+)`);
235+sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
236+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
237+ label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
238+ PRIMARY KEY (issue_id, label_id)
239+)`);
240+sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
241+ patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
242+ label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
243+ PRIMARY KEY (patch_id, label_id)
244+)`);
245+
204246 export async function getRepo(name: string, isAdmin: boolean) {
205247 const repo = await db
206248 .selectFrom("repositories")
Msrc/db/schema.sql
@@ -136,3 +136,24 @@ CREATE TABLE IF NOT EXISTS release_assets (
136136 content_type TEXT NOT NULL,
137137 created_at TEXT NOT NULL
138138 );
139+
140+CREATE TABLE IF NOT EXISTS labels (
141+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
143+ name TEXT NOT NULL,
144+ color TEXT NOT NULL DEFAULT '#808080',
145+ created_at TEXT NOT NULL,
146+ UNIQUE(repo_id, name)
147+);
148+
149+CREATE TABLE IF NOT EXISTS issue_labels (
150+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
151+ label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
152+ PRIMARY KEY (issue_id, label_id)
153+);
154+
155+CREATE TABLE IF NOT EXISTS patch_labels (
156+ patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
157+ label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
158+ PRIMARY KEY (patch_id, label_id)
159+);
Asrc/lib/labelColor.ts
@@ -0,0 +1,7 @@
1+export function labelTextColor(hex: string): string {
2+ const r = parseInt(hex.slice(1, 3), 16);
3+ const g = parseInt(hex.slice(3, 5), 16);
4+ const b = parseInt(hex.slice(5, 7), 16);
5+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
6+ return luminance > 0.5 ? "#000000" : "#ffffff";
7+}
Msrc/routes/issues.tsx
@@ -1,7 +1,7 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
33 import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
4-import { db, getRepo } from "../db/index.ts";
4+import { db, getRepo, type LabelRow } from "../db/index.ts";
55 import {
66 requireAdmin,
77 requireAuth,
@@ -33,12 +33,44 @@ export const issueRoutes = new Elysia()
3333 : ("open" as const);
3434 const page = Math.max(1, query.page ?? 1);
3535
36- const allCounts = await db
37- .selectFrom("issues")
38- .select(["status", db.fn.countAll<number>().as("count")])
36+ // Parse label filter: query.labels may be a string or array of strings
37+ const rawLabels = query.labels;
38+ const labelIds: number[] = (
39+ Array.isArray(rawLabels)
40+ ? rawLabels
41+ : rawLabels
42+ ? [rawLabels]
43+ : []
44+ )
45+ .map((v) => parseInt(v, 10))
46+ .filter((n) => !isNaN(n));
47+
48+ const repoLabels = await db
49+ .selectFrom("labels")
50+ .selectAll()
3951 .where("repo_id", "=", repo.id)
40- .groupBy("status")
52+ .orderBy("name", "asc")
4153 .execute();
54+
55+ let countQuery = db
56+ .selectFrom("issues")
57+ .select(["issues.status", db.fn.countAll<number>().as("count")])
58+ .where("issues.repo_id", "=", repo.id);
59+ if (labelIds.length > 0) {
60+ countQuery = countQuery.where(({ exists, selectFrom }) =>
61+ exists(
62+ selectFrom("issue_labels")
63+ .select("issue_labels.issue_id")
64+ .whereRef(
65+ "issue_labels.issue_id",
66+ "=",
67+ "issues.id",
68+ )
69+ .where("issue_labels.label_id", "in", labelIds),
70+ ),
71+ );
72+ }
73+ const allCounts = await countQuery.groupBy("issues.status").execute();
4274 const counts: Record<string, number> = Object.fromEntries(
4375 allCounts.map((r) => [r.status, Number(r.count)]),
4476 );
@@ -49,7 +81,7 @@ export const issueRoutes = new Elysia()
4981 const safePage = Math.min(page, totalPages);
5082 const offset = (safePage - 1) * ISSUES_PER_PAGE;
5183
52- const issues = await db
84+ let listQuery = db
5385 .selectFrom("issues")
5486 .leftJoin("users", "users.id", "issues.author_id")
5587 .select([
@@ -67,16 +99,62 @@ export const issueRoutes = new Elysia()
6799 "users.avatar_version as author_avatar_version",
68100 ])
69101 .where("issues.repo_id", "=", repo.id)
70- .where("issues.status", "=", status)
102+ .where("issues.status", "=", status);
103+ if (labelIds.length > 0) {
104+ listQuery = listQuery.where(({ exists, selectFrom }) =>
105+ exists(
106+ selectFrom("issue_labels")
107+ .select("issue_labels.issue_id")
108+ .whereRef(
109+ "issue_labels.issue_id",
110+ "=",
111+ "issues.id",
112+ )
113+ .where("issue_labels.label_id", "in", labelIds),
114+ ),
115+ );
116+ }
117+ const issues = await listQuery
71118 .orderBy("issues.number", "desc")
72119 .limit(ISSUES_PER_PAGE)
73120 .offset(offset)
74121 .execute();
75122
123+ // Batch-fetch labels for displayed issues
124+ const issueIds = issues.map((i) => i.id);
125+ const issueLabelsRows =
126+ issueIds.length > 0
127+ ? await db
128+ .selectFrom("issue_labels")
129+ .innerJoin(
130+ "labels",
131+ "labels.id",
132+ "issue_labels.label_id",
133+ )
134+ .select([
135+ "issue_labels.issue_id",
136+ "labels.id",
137+ "labels.name",
138+ "labels.color",
139+ ])
140+ .where("issue_labels.issue_id", "in", issueIds)
141+ .execute()
142+ : [];
143+ const labelsByIssueId = new Map<number, LabelRow[]>();
144+ for (const row of issueLabelsRows) {
145+ const list = labelsByIssueId.get(row.issue_id) ?? [];
146+ list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" });
147+ labelsByIssueId.set(row.issue_id, list);
148+ }
149+
150+ const labelsParam =
151+ labelIds.length > 0
152+ ? `&labels=${labelIds.map(String).join(",")}`
153+ : "";
76154 const pagination = {
77155 page: safePage,
78156 totalPages,
79- pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`,
157+ pageUrlTemplate: `/${repo.name}/issues?status=${status}${labelsParam}&page={page}`,
80158 };
81159 return html(
82160 <IssueList
@@ -91,6 +169,9 @@ export const issueRoutes = new Elysia()
91169 status={status}
92170 counts={counts}
93171 pagination={pagination}
172+ repoLabels={repoLabels}
173+ selectedLabelIds={labelIds}
174+ labelsByIssueId={labelsByIssueId}
94175 />,
95176 );
96177 },
@@ -98,6 +179,7 @@ export const issueRoutes = new Elysia()
98179 query: t.Object({
99180 status: t.Optional(t.String()),
100181 page: t.Optional(t.Numeric()),
182+ labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
101183 }),
102184 },
103185 )
@@ -241,6 +323,20 @@ export const issueRoutes = new Elysia()
241323 ]),
242324 );
243325
326+ const issueLabels = await db
327+ .selectFrom("issue_labels")
328+ .innerJoin("labels", "labels.id", "issue_labels.label_id")
329+ .select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"])
330+ .where("issue_labels.issue_id", "=", issue.id)
331+ .execute();
332+
333+ const repoLabels = await db
334+ .selectFrom("labels")
335+ .selectAll()
336+ .where("repo_id", "=", repo.id)
337+ .orderBy("name", "asc")
338+ .execute();
339+
244340 return html(
245341 <IssueDetail
246342 user={user}
@@ -260,6 +356,8 @@ export const issueRoutes = new Elysia()
260356 }
261357 reactions={reactions}
262358 commentReactions={commentReactions}
359+ issueLabels={issueLabels}
360+ repoLabels={repoLabels}
263361 />,
264362 );
265363 })
@@ -583,4 +681,87 @@ export const issueRoutes = new Elysia()
583681 }),
584682 body: t.Object({ edit_body: t.String() }),
585683 },
684+ )
685+
686+ .post(
687+ "/:repo/issues/:number/labels/add",
688+ async ({ params, body, cookie }) => {
689+ const user = await resolveSession(cookie.session.value);
690+ const deny = requireAdmin(user);
691+ if (deny) return deny;
692+ const repo = await getRepo(params.repo, true);
693+ if (!repo) return new Response("Not found", { status: 404 });
694+
695+ const issueNum = parseInt(params.number, 10);
696+ const issue = await db
697+ .selectFrom("issues")
698+ .select(["id"])
699+ .where("repo_id", "=", repo.id)
700+ .where("number", "=", issueNum)
701+ .executeTakeFirst();
702+ if (!issue) return new Response("Not found", { status: 404 });
703+
704+ const label = await db
705+ .selectFrom("labels")
706+ .select(["id"])
707+ .where("id", "=", body.label_id)
708+ .where("repo_id", "=", repo.id)
709+ .executeTakeFirst();
710+ if (!label) {
711+ return new Response(null, {
712+ status: 302,
713+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
714+ });
715+ }
716+
717+ await db
718+ .insertInto("issue_labels")
719+ .values({ issue_id: issue.id, label_id: label.id })
720+ .onConflict((oc) => oc.doNothing())
721+ .execute();
722+
723+ return new Response(null, {
724+ status: 302,
725+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
726+ });
727+ },
728+ {
729+ params: t.Object({ repo: t.String(), number: t.String() }),
730+ body: t.Object({ label_id: t.Numeric() }),
731+ },
732+ )
733+
734+ .post(
735+ "/:repo/issues/:number/labels/remove",
736+ async ({ params, body, cookie }) => {
737+ const user = await resolveSession(cookie.session.value);
738+ const deny = requireAdmin(user);
739+ if (deny) return deny;
740+ const repo = await getRepo(params.repo, true);
741+ if (!repo) return new Response("Not found", { status: 404 });
742+
743+ const issueNum = parseInt(params.number, 10);
744+ const issue = await db
745+ .selectFrom("issues")
746+ .select(["id"])
747+ .where("repo_id", "=", repo.id)
748+ .where("number", "=", issueNum)
749+ .executeTakeFirst();
750+ if (!issue) return new Response("Not found", { status: 404 });
751+
752+ await db
753+ .deleteFrom("issue_labels")
754+ .where("issue_id", "=", issue.id)
755+ .where("label_id", "=", body.label_id)
756+ .execute();
757+
758+ return new Response(null, {
759+ status: 302,
760+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
761+ });
762+ },
763+ {
764+ params: t.Object({ repo: t.String(), number: t.String() }),
765+ body: t.Object({ label_id: t.Numeric() }),
766+ },
586767 );
Msrc/routes/patches.tsx
@@ -6,7 +6,7 @@ import {
66 MAX_USER_UPLOAD_BYTES,
77 } from "../config.ts";
88 import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
9-import { db, getRepo } from "../db/index.ts";
9+import { db, getRepo, type LabelRow } from "../db/index.ts";
1010 import {
1111 requireAdmin,
1212 requireAuth,
@@ -66,12 +66,44 @@ export const patchRoutes = new Elysia()
6666 : "open";
6767 const page = Math.max(1, query.page ?? 1);
6868
69- const allCounts = await db
70- .selectFrom("patches")
71- .select(["status", db.fn.countAll<number>().as("count")])
69+ // Parse label filter
70+ const rawLabels = query.labels;
71+ const labelIds: number[] = (
72+ Array.isArray(rawLabels)
73+ ? rawLabels
74+ : rawLabels
75+ ? [rawLabels]
76+ : []
77+ )
78+ .map((v) => parseInt(v, 10))
79+ .filter((n) => !isNaN(n));
80+
81+ const repoLabels = await db
82+ .selectFrom("labels")
83+ .selectAll()
7284 .where("repo_id", "=", repo.id)
73- .groupBy("status")
85+ .orderBy("name", "asc")
7486 .execute();
87+
88+ let countQuery = db
89+ .selectFrom("patches")
90+ .select(["patches.status", db.fn.countAll<number>().as("count")])
91+ .where("patches.repo_id", "=", repo.id);
92+ if (labelIds.length > 0) {
93+ countQuery = countQuery.where(({ exists, selectFrom }) =>
94+ exists(
95+ selectFrom("patch_labels")
96+ .select("patch_labels.patch_id")
97+ .whereRef(
98+ "patch_labels.patch_id",
99+ "=",
100+ "patches.id",
101+ )
102+ .where("patch_labels.label_id", "in", labelIds),
103+ ),
104+ );
105+ }
106+ const allCounts = await countQuery.groupBy("patches.status").execute();
75107 const counts: Record<string, number> = Object.fromEntries(
76108 allCounts.map((r) => [r.status, Number(r.count)]),
77109 );
@@ -82,7 +114,7 @@ export const patchRoutes = new Elysia()
82114 const safePage = Math.min(page, totalPages);
83115 const offset = (safePage - 1) * PATCHES_PER_PAGE;
84116
85- const patches = await db
117+ let listQuery = db
86118 .selectFrom("patches")
87119 .leftJoin("users", "users.id", "patches.author_id")
88120 .select([
@@ -104,16 +136,62 @@ export const patchRoutes = new Elysia()
104136 "users.avatar_version as author_avatar_version",
105137 ])
106138 .where("patches.repo_id", "=", repo.id)
107- .where("patches.status", "=", status)
139+ .where("patches.status", "=", status);
140+ if (labelIds.length > 0) {
141+ listQuery = listQuery.where(({ exists, selectFrom }) =>
142+ exists(
143+ selectFrom("patch_labels")
144+ .select("patch_labels.patch_id")
145+ .whereRef(
146+ "patch_labels.patch_id",
147+ "=",
148+ "patches.id",
149+ )
150+ .where("patch_labels.label_id", "in", labelIds),
151+ ),
152+ );
153+ }
154+ const patches = await listQuery
108155 .orderBy("patches.number", "desc")
109156 .limit(PATCHES_PER_PAGE)
110157 .offset(offset)
111158 .execute();
112159
160+ // Batch-fetch labels for displayed patches
161+ const patchIds = patches.map((p) => p.id);
162+ const patchLabelsRows =
163+ patchIds.length > 0
164+ ? await db
165+ .selectFrom("patch_labels")
166+ .innerJoin(
167+ "labels",
168+ "labels.id",
169+ "patch_labels.label_id",
170+ )
171+ .select([
172+ "patch_labels.patch_id",
173+ "labels.id",
174+ "labels.name",
175+ "labels.color",
176+ ])
177+ .where("patch_labels.patch_id", "in", patchIds)
178+ .execute()
179+ : [];
180+ const labelsByPatchId = new Map<number, LabelRow[]>();
181+ for (const row of patchLabelsRows) {
182+ const list = labelsByPatchId.get(row.patch_id) ?? [];
183+ list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" });
184+ labelsByPatchId.set(row.patch_id, list);
185+ }
186+
187+ const labelsParam =
188+ labelIds.length > 0
189+ ? `&labels=${labelIds.map(String).join(",")}`
190+ : "";
113191 const pagination = {
114192 page: safePage,
115193 totalPages,
116- pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
194+ pageUrlTemplate: `/${repo.name}/patches?status=${status}${labelsParam}&page={page}`,
117195 };
118196 return html(
119197 <PatchList
@@ -128,6 +206,9 @@ export const patchRoutes = new Elysia()
128206 status={status}
129207 counts={counts}
130208 pagination={pagination}
209+ repoLabels={repoLabels}
210+ selectedLabelIds={labelIds}
211+ labelsByPatchId={labelsByPatchId}
131212 />,
132213 );
133214 },
@@ -135,6 +216,7 @@ export const patchRoutes = new Elysia()
135216 query: t.Object({
136217 status: t.Optional(t.String()),
137218 page: t.Optional(t.Numeric()),
219+ labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
138220 }),
139221 },
140222 )
@@ -386,6 +468,20 @@ export const patchRoutes = new Elysia()
386468
387469 const patchMeta = extractPatchMeta(patch.patch_content);
388470
471+ const patchLabels = await db
472+ .selectFrom("patch_labels")
473+ .innerJoin("labels", "labels.id", "patch_labels.label_id")
474+ .select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"])
475+ .where("patch_labels.patch_id", "=", patch.id)
476+ .execute();
477+
478+ const repoLabels = await db
479+ .selectFrom("labels")
480+ .selectAll()
481+ .where("repo_id", "=", repo.id)
482+ .orderBy("name", "asc")
483+ .execute();
484+
389485 return html(
390486 <PatchDetail
391487 user={user}
@@ -411,6 +507,8 @@ export const patchRoutes = new Elysia()
411507 }
412508 reactions={reactions}
413509 commentReactions={commentReactions}
510+ patchLabels={patchLabels}
511+ repoLabels={repoLabels}
414512 />,
415513 );
416514 },
@@ -861,4 +959,87 @@ export const patchRoutes = new Elysia()
861959 edit_description: t.Optional(t.String()),
862960 }),
863961 },
962+ )
963+
964+ .post(
965+ "/:repo/patches/:number/labels/add",
966+ async ({ params, body, cookie }) => {
967+ const user = await resolveSession(cookie.session.value);
968+ const deny = requireAdmin(user);
969+ if (deny) return deny;
970+ const repo = await getRepo(params.repo, true);
971+ if (!repo) return new Response("Not found", { status: 404 });
972+
973+ const patchNum = parseInt(params.number, 10);
974+ const patch = await db
975+ .selectFrom("patches")
976+ .select(["id"])
977+ .where("repo_id", "=", repo.id)
978+ .where("number", "=", patchNum)
979+ .executeTakeFirst();
980+ if (!patch) return new Response("Not found", { status: 404 });
981+
982+ const label = await db
983+ .selectFrom("labels")
984+ .select(["id"])
985+ .where("id", "=", body.label_id)
986+ .where("repo_id", "=", repo.id)
987+ .executeTakeFirst();
988+ if (!label) {
989+ return new Response(null, {
990+ status: 302,
991+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
992+ });
993+ }
994+
995+ await db
996+ .insertInto("patch_labels")
997+ .values({ patch_id: patch.id, label_id: label.id })
998+ .onConflict((oc) => oc.doNothing())
999+ .execute();
1000+
1001+ return new Response(null, {
1002+ status: 302,
1003+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
1004+ });
1005+ },
1006+ {
1007+ params: t.Object({ repo: t.String(), number: t.String() }),
1008+ body: t.Object({ label_id: t.Numeric() }),
1009+ },
1010+ )
1011+
1012+ .post(
1013+ "/:repo/patches/:number/labels/remove",
1014+ async ({ params, body, cookie }) => {
1015+ const user = await resolveSession(cookie.session.value);
1016+ const deny = requireAdmin(user);
1017+ if (deny) return deny;
1018+ const repo = await getRepo(params.repo, true);
1019+ if (!repo) return new Response("Not found", { status: 404 });
1020+
1021+ const patchNum = parseInt(params.number, 10);
1022+ const patch = await db
1023+ .selectFrom("patches")
1024+ .select(["id"])
1025+ .where("repo_id", "=", repo.id)
1026+ .where("number", "=", patchNum)
1027+ .executeTakeFirst();
1028+ if (!patch) return new Response("Not found", { status: 404 });
1029+
1030+ await db
1031+ .deleteFrom("patch_labels")
1032+ .where("patch_id", "=", patch.id)
1033+ .where("label_id", "=", body.label_id)
1034+ .execute();
1035+
1036+ return new Response(null, {
1037+ status: 302,
1038+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
1039+ });
1040+ },
1041+ {
1042+ params: t.Object({ repo: t.String(), number: t.String() }),
1043+ body: t.Object({ label_id: t.Numeric() }),
1044+ },
8641045 );
Msrc/routes/repos.tsx
@@ -9,7 +9,7 @@ import {
99 REPOS_PER_PAGE,
1010 VALID_REPO_NAME_RE,
1111 } from "../constants.ts";
12-import { db } from "../db/index.ts";
12+import { db, type LabelRow } from "../db/index.ts";
1313 import { redirect } from "../lib/redirect.ts";
1414 import { requireAdmin, resolveSession } from "../middleware/session.ts";
1515 import { git, repoPath } from "../services/git.ts";
@@ -698,15 +698,30 @@ export const repoRoutes = new Elysia()
698698 );
699699 })
700700
701- .get("/:repo/settings", async ({ params, cookie }) => {
701+ .get("/:repo/settings", async ({ params, query, cookie }) => {
702702 const user = await resolveSession(cookie.session.value);
703703 const deny = requireAdmin(user);
704704 if (deny) return deny;
705705 const repo = await getRepo(params.repo, true);
706706 if (!repo) return new Response("Not found", { status: 404 });
707707 const branches = await git.branches(repo.name);
708+ const labels = await db
709+ .selectFrom("labels")
710+ .selectAll()
711+ .where("repo_id", "=", repo.id)
712+ .orderBy("name", "asc")
713+ .execute();
714+ const success = typeof query.success === "string" ? query.success : undefined;
715+ const error = typeof query.error === "string" ? query.error : undefined;
708716 return html(
709- <RepoSettings user={user!} repo={repo} branches={branches} />,
717+ <RepoSettings
718+ user={user!}
719+ repo={repo}
720+ branches={branches}
721+ labels={labels}
722+ success={success}
723+ error={error}
724+ />,
710725 );
711726 })
712727
@@ -733,13 +748,8 @@ export const repoRoutes = new Elysia()
733748
734749 // Validate the selected branch exists (only if repo has commits)
735750 if (branches.length > 0 && !branches.includes(newBranch)) {
736- return html(
737- <RepoSettings
738- user={user!}
739- repo={repo}
740- branches={branches}
741- error={`Branch "${newBranch}" does not exist.`}
742- />,
751+ return redirect(
752+ `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
743753 );
744754 }
745755
@@ -761,19 +771,7 @@ export const repoRoutes = new Elysia()
761771 await git.setHead(repo.name, newBranch).catch(() => {});
762772 }
763773
764- const updated = await db
765- .selectFrom("repositories")
766- .selectAll()
767- .where("id", "=", repo.id)
768- .executeTakeFirstOrThrow();
769- return html(
770- <RepoSettings
771- user={user!}
772- repo={updated}
773- branches={branches}
774- success="Settings saved."
775- />,
776- );
774+ return redirect(`/${repo.name}/settings?success=Settings+saved.`);
777775 },
778776 {
779777 body: t.Object({
@@ -800,4 +798,83 @@ export const repoRoutes = new Elysia()
800798 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
801799
802800 return new Response(null, { status: 302, headers: { Location: "/" } });
803- });
801+ })
802+
803+ .post(
804+ "/:repo/settings/labels",
805+ async ({ params, body, cookie }) => {
806+ const user = await resolveSession(cookie.session.value);
807+ const deny = requireAdmin(user);
808+ if (deny) return deny;
809+ const repo = await getRepo(params.repo, true);
810+ if (!repo) return new Response("Not found", { status: 404 });
811+
812+ const name = body.name?.trim();
813+ const color = body.color?.trim();
814+
815+ if (!name || name.length > 50) {
816+ return redirect(
817+ `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
818+ );
819+ }
820+ if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
821+ return redirect(
822+ `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
823+ );
824+ }
825+
826+ try {
827+ await db
828+ .insertInto("labels")
829+ .values({
830+ repo_id: repo.id,
831+ name,
832+ color,
833+ created_at: new Date().toISOString(),
834+ })
835+ .execute();
836+ } catch {
837+ return redirect(
838+ `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
839+ );
840+ }
841+
842+ return redirect(`/${repo.name}/settings?success=Label+created.`);
843+ },
844+ {
845+ body: t.Object({
846+ name: t.String(),
847+ color: t.String(),
848+ }),
849+ },
850+ )
851+
852+ .post(
853+ "/:repo/settings/labels/delete",
854+ async ({ params, body, cookie }) => {
855+ const user = await resolveSession(cookie.session.value);
856+ const deny = requireAdmin(user);
857+ if (deny) return deny;
858+ const repo = await getRepo(params.repo, true);
859+ if (!repo) return new Response("Not found", { status: 404 });
860+
861+ const label = await db
862+ .selectFrom("labels")
863+ .select(["id", "repo_id"])
864+ .where("id", "=", body.id)
865+ .executeTakeFirst();
866+
867+ if (!label || label.repo_id !== repo.id) {
868+ return redirect(
869+ `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
870+ );
871+ }
872+
873+ await db.deleteFrom("labels").where("id", "=", body.id).execute();
874+
875+ return redirect(`/${repo.name}/settings?success=Label+deleted.`);
876+ },
877+ {
878+ body: t.Object({ id: t.Numeric() }),
879+ },
880+ );
Msrc/styles/main.css
@@ -2719,3 +2719,174 @@
27192719 color: var(--color-text-muted);
27202720 font-size: var(--text-xs);
27212721 }
2722+
2723+/* Labels */
2724+.label-badge {
2725+ display: inline-block;
2726+ padding: 0 var(--space-2);
2727+ border-radius: 2em;
2728+ font-size: var(--text-xs);
2729+ font-weight: 500;
2730+ line-height: 1.6;
2731+ word-break: break-all;
2732+}
2733+.issue-labels {
2734+ display: flex;
2735+ flex-wrap: wrap;
2736+ gap: var(--space-1);
2737+ margin-bottom: var(--space-2);
2738+}
2739+.issue-labels-row {
2740+ display: flex;
2741+ flex-wrap: wrap;
2742+ align-items: center;
2743+ gap: var(--space-2);
2744+ padding: var(--space-2) 0 var(--space-3);
2745+}
2746+.label-badge-wrap {
2747+ display: inline-flex;
2748+ align-items: center;
2749+ gap: var(--space-1);
2750+}
2751+.label-remove-form {
2752+ display: inline;
2753+}
2754+.label-remove-btn {
2755+ background: none;
2756+ border: none;
2757+ cursor: pointer;
2758+ padding: 0 2px;
2759+ font-size: var(--text-sm);
2760+ line-height: 1;
2761+ color: var(--color-text-muted);
2762+ opacity: 0.7;
2763+}
2764+.label-remove-btn:hover {
2765+ opacity: 1;
2766+}
2767+.label-add-inline-form {
2768+ display: inline-flex;
2769+ align-items: center;
2770+ gap: var(--space-2);
2771+}
2772+.label-select {
2773+ font-size: var(--text-sm);
2774+ padding: var(--space-1) var(--space-2);
2775+ border: 1px solid var(--color-border);
2776+ border-radius: var(--radius-md);
2777+ background: var(--color-bg);
2778+ color: var(--color-text);
2779+ height: 2rem;
2780+}
2781+.label-settings-list {
2782+ display: flex;
2783+ flex-direction: column;
2784+ margin-bottom: var(--space-4);
2785+ max-height: 24rem;
2786+ overflow-y: auto;
2787+}
2788+.label-settings-item {
2789+ display: flex;
2790+ align-items: center;
2791+ gap: var(--space-3);
2792+ padding: var(--space-2) 0;
2793+ border-bottom: 1px solid var(--color-border-muted);
2794+ font-size: var(--text-sm);
2795+}
2796+.label-settings-item:last-child {
2797+ border-bottom: none;
2798+}
2799+.label-settings-swatch {
2800+ width: 12px;
2801+ height: 12px;
2802+ border-radius: 50%;
2803+ flex-shrink: 0;
2804+ border: 1px solid rgba(0, 0, 0, 0.15);
2805+}
2806+.label-settings-name {
2807+ flex: 1;
2808+ font-weight: 500;
2809+}
2810+.label-add-form {
2811+ display: flex;
2812+ align-items: center;
2813+ gap: var(--space-2);
2814+}
2815+.label-name-input {
2816+ flex: 1;
2817+ min-width: 0;
2818+ padding: var(--space-2) var(--space-3);
2819+ border: 1px solid var(--color-border);
2820+ border-radius: var(--radius-md);
2821+ background: var(--color-bg);
2822+ color: var(--color-text);
2823+ font-size: var(--text-sm);
2824+ height: 2rem;
2825+ box-sizing: border-box;
2826+}
2827+.label-name-input:focus {
2828+ outline: 2px solid var(--color-accent);
2829+ outline-offset: -1px;
2830+ border-color: var(--color-accent);
2831+}
2832+.label-color-swatch-label {
2833+ flex-shrink: 0;
2834+ cursor: pointer;
2835+ display: block;
2836+ line-height: 0;
2837+}
2838+.label-color-input {
2839+ display: block;
2840+ width: 2rem;
2841+ height: 2rem;
2842+ padding: 2px;
2843+ border: 1px solid var(--color-border);
2844+ border-radius: var(--radius-md);
2845+ background: var(--color-bg);
2846+ cursor: pointer;
2847+ -webkit-appearance: none;
2848+ appearance: none;
2849+ box-sizing: border-box;
2850+}
2851+
2852+/* Label filter (details/summary popup) */
2853+.list-header-actions {
2854+ display: flex;
2855+ align-items: center;
2856+ gap: var(--space-2);
2857+ flex-shrink: 0;
2858+}
2859+.label-filter {
2860+ position: relative;
2861+}
2862+.label-filter-popup {
2863+ position: absolute;
2864+ right: 0;
2865+ top: calc(100% + var(--space-1));
2866+ z-index: 10;
2867+ background: var(--color-bg);
2868+ border: 1px solid var(--color-border);
2869+ border-radius: var(--radius-lg);
2870+ padding: var(--space-3);
2871+ min-width: 180px;
2872+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
2873+}
2874+.label-filter-list {
2875+ display: flex;
2876+ flex-direction: column;
2877+ gap: var(--space-2);
2878+ margin-bottom: var(--space-3);
2879+ max-height: 16rem;
2880+ overflow-y: auto;
2881+}
2882+.label-filter-item {
2883+ display: flex;
2884+ align-items: center;
2885+ gap: var(--space-2);
2886+ cursor: pointer;
2887+ font-size: var(--text-sm);
2888+}
2889+.label-filter-actions {
2890+ display: flex;
2891+ gap: var(--space-2);
2892+}
Msrc/views/issues/IssueDetail.tsx
@@ -1,8 +1,10 @@
11 import type {
22 IssueCommentRow,
33 IssueRow,
4+ LabelRow,
45 RepositoryRow,
56 } from "../../db/index.ts";
7+import { labelTextColor } from "../../lib/labelColor.ts";
68 import { displayName } from "../../lib/users.ts";
79 import type { SessionUser } from "../../middleware/session.ts";
810 import { Avatar } from "../Avatar.tsx";
@@ -27,6 +29,8 @@ interface IssueDetailProps {
2729 })[];
2830 reactions: ReactionCount[];
2931 commentReactions: Map<number, ReactionCount[]>;
32+ issueLabels: LabelRow[];
33+ repoLabels: LabelRow[];
3034 }
3135
3236 export function IssueDetail({
@@ -37,6 +41,8 @@ export function IssueDetail({
3741 comments,
3842 reactions,
3943 commentReactions,
44+ issueLabels,
45+ repoLabels,
4046 }: IssueDetailProps) {
4147 const canEditIssue =
4248 user != null &&
@@ -158,6 +164,75 @@ export function IssueDetail({
158164 )}
159165 </div>
160166
167+ {(issueLabels.length > 0 || user?.isAdmin) && (
168+ <div class="issue-labels-row">
169+ {issueLabels.map((label) => (
170+ <span class="label-badge-wrap">
171+ <span
172+ class="label-badge"
173+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
174+ >
175+ {label.name}
176+ </span>
177+ {user?.isAdmin && (
178+ <form
179+ method="POST"
180+ action={`/${repo.name}/issues/${issue.number}/labels/remove`}
181+ class="label-remove-form"
182+ >
183+ <input
184+ type="hidden"
185+ name="label_id"
186+ value={String(label.id)}
187+ />
188+ <button
189+ type="submit"
190+ class="label-remove-btn"
191+ title="Remove label"
192+ >
193+ ×
194+ </button>
195+ </form>
196+ )}
197+ </span>
198+ ))}
199+ {user?.isAdmin &&
200+ repoLabels.filter(
201+ (l) =>
202+ !issueLabels.some((il) => il.id === l.id),
203+ ).length > 0 && (
204+ <form
205+ method="POST"
206+ action={`/${repo.name}/issues/${issue.number}/labels/add`}
207+ class="label-add-inline-form"
208+ >
209+ <select name="label_id" class="label-select">
210+ {repoLabels
211+ .filter(
212+ (l) =>
213+ !issueLabels.some(
214+ (il) => il.id === l.id,
215+ ),
216+ )
217+ .map((label) => (
218+ <option
219+ value={String(label.id)}
220+ >
221+ {label.name}
222+ </option>
223+ ))}
224+ </select>
225+ <button
226+ type="submit"
227+ class="btn btn-sm btn-secondary"
228+ >
229+ Add label
230+ </button>
231+ </form>
232+ )}
233+ </div>
234+ )}
235+
161236 <div class="timeline-item">
162237 <div class="timeline-author">
163238 <Avatar
Msrc/views/issues/IssueList.tsx
@@ -1,5 +1,6 @@
1-import type { IssueRow, RepositoryRow } from "../../db/index.ts";
1+import type { IssueRow, LabelRow, RepositoryRow } from "../../db/index.ts";
22 import { formatDate } from "../../lib/formatDate.ts";
3+import { labelTextColor } from "../../lib/labelColor.ts";
34 import { displayName } from "../../lib/users.ts";
45 import type { SessionUser } from "../../middleware/session.ts";
56 import { Avatar } from "../Avatar.tsx";
@@ -18,6 +19,9 @@ interface IssueListProps {
1819 status: "open" | "closed" | "completed";
1920 counts: Record<string, number>;
2021 pagination: PaginationInfo;
22+ repoLabels: LabelRow[];
23+ selectedLabelIds: number[];
24+ labelsByIssueId: Map<number, LabelRow[]>;
2125 }
2226
2327 export function IssueList({
@@ -27,7 +31,14 @@ export function IssueList({
2731 status,
2832 counts,
2933 pagination,
34+ repoLabels,
35+ selectedLabelIds,
36+ labelsByIssueId,
3037 }: IssueListProps) {
38+ const labelsParam =
39+ selectedLabelIds.length > 0
40+ ? `&labels=${selectedLabelIds.map(String).join(",")}`
41+ : "";
3142 return (
3243 <Layout user={user} title={`Issues — ${repo.name}`}>
3344 <div class="container">
@@ -36,7 +47,7 @@ export function IssueList({
3647 <div class="list-header">
3748 <div class="list-header-tabs">
3849 <a
39- href={`/${repo.name}/issues?status=open`}
50+ href={`/${repo.name}/issues?status=open${labelsParam}`}
4051 class={`list-tab${status === "open" ? " active" : ""}`}
4152 >
4253 Open{" "}
@@ -45,7 +56,7 @@ export function IssueList({
4556 )}
4657 </a>
4758 <a
48- href={`/${repo.name}/issues?status=completed`}
59+ href={`/${repo.name}/issues?status=completed${labelsParam}`}
4960 class={`list-tab${status === "completed" ? " active" : ""}`}
5061 >
5162 Completed{" "}
@@ -56,7 +67,7 @@ export function IssueList({
5667 )}
5768 </a>
5869 <a
59- href={`/${repo.name}/issues?status=closed`}
70+ href={`/${repo.name}/issues?status=closed${labelsParam}`}
6071 class={`list-tab${status === "closed" ? " active" : ""}`}
6172 >
6273 Closed{" "}
@@ -65,14 +76,78 @@ export function IssueList({
6576 )}
6677 </a>
6778 </div>
68- {user && (
69- <a
70- href={`/${repo.name}/issues/new`}
71- class="btn btn-primary btn-sm"
72- >
73- New issue
74- </a>
75- )}
79+ <div class="list-header-actions">
80+ {repoLabels.length > 0 && (
81+ <details class="label-filter">
82+ <summary class="btn btn-secondary btn-sm">
83+ Filter by label
84+ {selectedLabelIds.length > 0 && (
85+ <span class="tab-count">
86+ {selectedLabelIds.length}
87+ </span>
88+ )}
89+ </summary>
90+ <div class="label-filter-popup">
91+ <form
92+ method="GET"
93+ action={`/${repo.name}/issues`}
94+ >
95+ <input
96+ type="hidden"
97+ name="status"
98+ value={status}
99+ />
100+ <div class="label-filter-list">
101+ {repoLabels.map((label) => (
102+ <label class="label-filter-item">
103+ <input
104+ type="checkbox"
105+ name="labels"
106+ value={String(label.id)}
107+ checked={
108+ selectedLabelIds.includes(
109+ label.id,
110+ )
111+ ? true
112+ : undefined
113+ }
114+ />
115+ <span
116+ class="label-badge"
117+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
118+ >
119+ {label.name}
120+ </span>
121+ </label>
122+ ))}
123+ </div>
124+ <div class="label-filter-actions">
125+ <button
126+ type="submit"
127+ class="btn btn-primary btn-sm"
128+ >
129+ Apply
130+ </button>
131+ <a
132+ href={`/${repo.name}/issues?status=${status}`}
133+ class="btn btn-secondary btn-sm"
134+ >
135+ Clear
136+ </a>
137+ </div>
138+ </form>
139+ </div>
140+ </details>
141+ )}
142+ {user && (
143+ <a
144+ href={`/${repo.name}/issues/new`}
145+ class="btn btn-primary btn-sm"
146+ >
147+ New issue
148+ </a>
149+ )}
150+ </div>
76151 </div>
77152 {issues.length === 0 ? (
78153 <div class="empty-state">
@@ -80,38 +155,53 @@ export function IssueList({
80155 </div>
81156 ) : (
82157 <ul class="issue-list">
83- {issues.map((issue) => (
84- <li class="issue-item">
85- <div class="issue-main">
86- <span
87- class={`issue-status-dot ${issue.status}`}
88- />
89- <a
90- href={`/${repo.name}/issues/${issue.number}`}
91- class="issue-title"
92- >
93- {issue.title}
94- </a>
95- <span class="issue-number">
96- #{issue.number}
97- </span>
98- </div>
99- <div class="issue-meta">
100- <Avatar
101- userId={issue.author_id}
102- version={issue.author_avatar_version}
103- size={20}
104- />
105- <span class="issue-author">
106- opened by{" "}
107- {displayName(issue.author_username)}
108- </span>
109- <time datetime={issue.created_at}>
110- {formatDate(issue.created_at)}
111- </time>
112- </div>
113- </li>
114- ))}
158+ {issues.map((issue) => {
159+ const labels = labelsByIssueId.get(issue.id) ?? [];
160+ return (
161+ <li class="issue-item">
162+ <div class="issue-main">
163+ <span
164+ class={`issue-status-dot ${issue.status}`}
165+ />
166+ <a
167+ href={`/${repo.name}/issues/${issue.number}`}
168+ class="issue-title"
169+ >
170+ {issue.title}
171+ </a>
172+ <span class="issue-number">
173+ #{issue.number}
174+ </span>
175+ </div>
176+ {labels.length > 0 && (
177+ <div class="issue-labels">
178+ {labels.map((label) => (
179+ <span
180+ class="label-badge"
181+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
182+ >
183+ {label.name}
184+ </span>
185+ ))}
186+ </div>
187+ )}
188+ <div class="issue-meta">
189+ <Avatar
190+ userId={issue.author_id}
191+ version={issue.author_avatar_version}
192+ size={20}
193+ />
194+ <span class="issue-author">
195+ opened by{" "}
196+ {displayName(issue.author_username)}
197+ </span>
198+ <time datetime={issue.created_at}>
199+ {formatDate(issue.created_at)}
200+ </time>
201+ </div>
202+ </li>
203+ );
204+ })}
115205 </ul>
116206 )}
117207 <Pagination {...pagination} />
Msrc/views/patches/PatchDetail.tsx
@@ -1,10 +1,12 @@
11 import { escapeHtml } from "@kitajs/html";
22 import type {
3+ LabelRow,
34 PatchCommentRow,
45 PatchRow,
56 RepositoryRow,
67 } from "../../db/index.ts";
78 import { formatDateTime } from "../../lib/formatDate.ts";
9+import { labelTextColor } from "../../lib/labelColor.ts";
810 import { displayName } from "../../lib/users.ts";
911 import type { SessionUser } from "../../middleware/session.ts";
1012 import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
@@ -39,6 +41,8 @@ interface PatchDetailProps {
3941 })[];
4042 reactions: ReactionCount[];
4143 commentReactions: Map<number, ReactionCount[]>;
44+ patchLabels: LabelRow[];
45+ repoLabels: LabelRow[];
4246 }
4347
4448 export function PatchDetail({
@@ -53,6 +57,8 @@ export function PatchDetail({
5357 comments,
5458 reactions,
5559 commentReactions,
60+ patchLabels,
61+ repoLabels,
5662 }: PatchDetailProps) {
5763 const canEdit =
5864 user != null &&
@@ -214,6 +220,74 @@ export function PatchDetail({
214220 </div>
215221 )}
216222 </div>
223+ {(patchLabels.length > 0 || user?.isAdmin) && (
224+ <div class="issue-labels-row">
225+ {patchLabels.map((label) => (
226+ <span class="label-badge-wrap">
227+ <span
228+ class="label-badge"
229+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
230+ >
231+ {label.name}
232+ </span>
233+ {user?.isAdmin && (
234+ <form
235+ method="POST"
236+ action={`${baseUrl}/labels/remove`}
237+ class="label-remove-form"
238+ >
239+ <input
240+ type="hidden"
241+ name="label_id"
242+ value={String(label.id)}
243+ />
244+ <button
245+ type="submit"
246+ class="label-remove-btn"
247+ title="Remove label"
248+ >
249+ ×
250+ </button>
251+ </form>
252+ )}
253+ </span>
254+ ))}
255+ {user?.isAdmin &&
256+ repoLabels.filter(
257+ (l) =>
258+ !patchLabels.some((pl) => pl.id === l.id),
259+ ).length > 0 && (
260+ <form
261+ method="POST"
262+ action={`${baseUrl}/labels/add`}
263+ class="label-add-inline-form"
264+ >
265+ <select name="label_id" class="label-select">
266+ {repoLabels
267+ .filter(
268+ (l) =>
269+ !patchLabels.some(
270+ (pl) => pl.id === l.id,
271+ ),
272+ )
273+ .map((label) => (
274+ <option
275+ value={String(label.id)}
276+ >
277+ {label.name}
278+ </option>
279+ ))}
280+ </select>
281+ <button
282+ type="submit"
283+ class="btn btn-sm btn-secondary"
284+ >
285+ Add label
286+ </button>
287+ </form>
288+ )}
289+ </div>
290+ )}
217291 </div>
218292
219293 {/* Subview tabs */}
Msrc/views/patches/PatchList.tsx
@@ -1,5 +1,6 @@
1-import type { PatchRow, RepositoryRow } from "../../db/index.ts";
1+import type { LabelRow, PatchRow, RepositoryRow } from "../../db/index.ts";
22 import { formatDate } from "../../lib/formatDate.ts";
3+import { labelTextColor } from "../../lib/labelColor.ts";
34 import { displayName } from "../../lib/users.ts";
45 import type { SessionUser } from "../../middleware/session.ts";
56 import { Avatar } from "../Avatar.tsx";
@@ -18,6 +19,9 @@ interface PatchListProps {
1819 status: string;
1920 counts: Record<string, number>;
2021 pagination: PaginationInfo;
22+ repoLabels: LabelRow[];
23+ selectedLabelIds: number[];
24+ labelsByPatchId: Map<number, LabelRow[]>;
2125 }
2226
2327 export function PatchList({
@@ -27,7 +31,14 @@ export function PatchList({
2731 status,
2832 counts,
2933 pagination,
34+ repoLabels,
35+ selectedLabelIds,
36+ labelsByPatchId,
3037 }: PatchListProps) {
38+ const labelsParam =
39+ selectedLabelIds.length > 0
40+ ? `&labels=${selectedLabelIds.map(String).join(",")}`
41+ : "";
3142 return (
3243 <Layout user={user} title={`Patches — ${repo.name}`}>
3344 <div class="container">
@@ -36,7 +47,7 @@ export function PatchList({
3647 <div class="list-header">
3748 <div class="list-header-tabs">
3849 <a
39- href={`/${repo.name}/patches?status=open`}
50+ href={`/${repo.name}/patches?status=open${labelsParam}`}
4051 class={`list-tab${status === "open" ? " active" : ""}`}
4152 >
4253 Open{" "}
@@ -45,7 +56,7 @@ export function PatchList({
4556 )}
4657 </a>
4758 <a
48- href={`/${repo.name}/patches?status=merged`}
59+ href={`/${repo.name}/patches?status=merged${labelsParam}`}
4960 class={`list-tab${status === "merged" ? " active" : ""}`}
5061 >
5162 Merged{" "}
@@ -54,7 +65,7 @@ export function PatchList({
5465 )}
5566 </a>
5667 <a
57- href={`/${repo.name}/patches?status=closed`}
68+ href={`/${repo.name}/patches?status=closed${labelsParam}`}
5869 class={`list-tab${status === "closed" ? " active" : ""}`}
5970 >
6071 Closed{" "}
@@ -63,14 +74,78 @@ export function PatchList({
6374 )}
6475 </a>
6576 </div>
66- {user && (
67- <a
68- href={`/${repo.name}/patches/new`}
69- class="btn btn-primary btn-sm"
70- >
71- Upload patch
72- </a>
73- )}
77+ <div class="list-header-actions">
78+ {repoLabels.length > 0 && (
79+ <details class="label-filter">
80+ <summary class="btn btn-secondary btn-sm">
81+ Filter by label
82+ {selectedLabelIds.length > 0 && (
83+ <span class="tab-count">
84+ {selectedLabelIds.length}
85+ </span>
86+ )}
87+ </summary>
88+ <div class="label-filter-popup">
89+ <form
90+ method="GET"
91+ action={`/${repo.name}/patches`}
92+ >
93+ <input
94+ type="hidden"
95+ name="status"
96+ value={status}
97+ />
98+ <div class="label-filter-list">
99+ {repoLabels.map((label) => (
100+ <label class="label-filter-item">
101+ <input
102+ type="checkbox"
103+ name="labels"
104+ value={String(label.id)}
105+ checked={
106+ selectedLabelIds.includes(
107+ label.id,
108+ )
109+ ? true
110+ : undefined
111+ }
112+ />
113+ <span
114+ class="label-badge"
115+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
116+ >
117+ {label.name}
118+ </span>
119+ </label>
120+ ))}
121+ </div>
122+ <div class="label-filter-actions">
123+ <button
124+ type="submit"
125+ class="btn btn-primary btn-sm"
126+ >
127+ Apply
128+ </button>
129+ <a
130+ href={`/${repo.name}/patches?status=${status}`}
131+ class="btn btn-secondary btn-sm"
132+ >
133+ Clear
134+ </a>
135+ </div>
136+ </form>
137+ </div>
138+ </details>
139+ )}
140+ {user && (
141+ <a
142+ href={`/${repo.name}/patches/new`}
143+ class="btn btn-primary btn-sm"
144+ >
145+ Upload patch
146+ </a>
147+ )}
148+ </div>
74149 </div>
75150 {patches.length === 0 ? (
76151 <div class="empty-state">
@@ -78,37 +153,52 @@ export function PatchList({
78153 </div>
79154 ) : (
80155 <ul class="issue-list">
81- {patches.map((patch) => (
82- <li class="issue-item">
83- <div class="issue-main">
84- <span
85- class={`patch-status-dot ${patch.status}`}
86- />
87- <a
88- href={`/${repo.name}/patches/${patch.number}`}
89- class="issue-title"
90- >
91- {patch.title}
92- </a>
93- <span class="issue-number">
94- #{patch.number}
95- </span>
96- </div>
97- <div class="issue-meta">
98- <Avatar
99- userId={patch.author_id}
100- version={patch.author_avatar_version}
101- size={20}
102- />
103- <span>
104- by {displayName(patch.author_username)}
105- </span>
106- <time datetime={patch.created_at}>
107- {formatDate(patch.created_at)}
108- </time>
109- </div>
110- </li>
111- ))}
156+ {patches.map((patch) => {
157+ const labels = labelsByPatchId.get(patch.id) ?? [];
158+ return (
159+ <li class="issue-item">
160+ <div class="issue-main">
161+ <span
162+ class={`patch-status-dot ${patch.status}`}
163+ />
164+ <a
165+ href={`/${repo.name}/patches/${patch.number}`}
166+ class="issue-title"
167+ >
168+ {patch.title}
169+ </a>
170+ <span class="issue-number">
171+ #{patch.number}
172+ </span>
173+ </div>
174+ {labels.length > 0 && (
175+ <div class="issue-labels">
176+ {labels.map((label) => (
177+ <span
178+ class="label-badge"
179+ style={`background:${label.color};color:${labelTextColor(label.color)}`}
180+ >
181+ {label.name}
182+ </span>
183+ ))}
184+ </div>
185+ )}
186+ <div class="issue-meta">
187+ <Avatar
188+ userId={patch.author_id}
189+ version={patch.author_avatar_version}
190+ size={20}
191+ />
192+ <span>
193+ by {displayName(patch.author_username)}
194+ </span>
195+ <time datetime={patch.created_at}>
196+ {formatDate(patch.created_at)}
197+ </time>
198+ </div>
199+ </li>
200+ );
201+ })}
112202 </ul>
113203 )}
114204 <Pagination {...pagination} />
Msrc/views/repos/RepoSettings.tsx
@@ -1,4 +1,4 @@
1-import type { RepositoryRow } from "../../db/index.ts";
1+import type { LabelRow, RepositoryRow } from "../../db/index.ts";
22 import type { SessionUser } from "../../middleware/session.ts";
33 import { Layout } from "../layout.tsx";
44 import { RepoHeader } from "./RepoHeader.tsx";
@@ -8,6 +8,7 @@ interface RepoSettingsProps {
88 user: SessionUser;
99 repo: RepositoryRow;
1010 branches: string[];
11+ labels: LabelRow[];
1112 success?: string;
1213 error?: string;
1314 }
@@ -16,6 +17,7 @@ export function RepoSettings({
1617 user,
1718 repo,
1819 branches,
20+ labels,
1921 success,
2022 error,
2123 }: RepoSettingsProps) {
@@ -129,6 +131,65 @@ export function RepoSettings({
129131 Save settings
130132 </button>
131133 </form>
134+ <div class="form-card">
135+ <h2 class="section-title">Labels</h2>
136+ {labels.length > 0 && (
137+ <div class="label-settings-list">
138+ {labels.map((label) => (
139+ <div class="label-settings-item">
140+ <span
141+ class="label-settings-swatch"
142+ style={`background:${label.color}`}
143+ />
144+ <span class="label-settings-name">
145+ {label.name}
146+ </span>
147+ <form
148+ method="POST"
149+ action={`/${repo.name}/settings/labels/delete`}
150+ >
151+ <input
152+ type="hidden"
153+ name="id"
154+ value={String(label.id)}
155+ />
156+ <button
157+ class="btn btn-danger btn-sm"
158+ type="submit"
159+ >
160+ Delete
161+ </button>
162+ </form>
163+ </div>
164+ ))}
165+ </div>
166+ )}
167+ <form
168+ method="POST"
169+ action={`/${repo.name}/settings/labels`}
170+ class="label-add-form"
171+ >
172+ <input
173+ class="form-input label-name-input"
174+ type="text"
175+ name="name"
176+ placeholder="Label name"
177+ maxlength="50"
178+ required
179+ />
180+ <label class="label-color-swatch-label" title="Pick a color">
181+ <input
182+ type="color"
183+ name="color"
184+ value="#808080"
185+ class="label-color-input"
186+ />
187+ </label>
188+ <button type="submit" class="btn btn-secondary btn-sm">
189+ Add label
190+ </button>
191+ </form>
192+ </div>
132193 <div class="danger-zone">
133194 <h2 class="section-title danger-title">Danger zone</h2>
134195 <div class="form-card danger-card">
Mtests/e2e.test.ts
@@ -2888,3 +2888,259 @@ describe('file editing', () => {
28882888 } finally { await page.close(); }
28892889 });
28902890 });
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+});