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
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- Issue labels
- 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 {
created_at: string;
}
interface LabelTable {
id: Generated<number>;
repo_id: number;
name: string;
color: string;
created_at: string;
}
interface IssueLabelTable {
issue_id: number;
label_id: number;
}
interface PatchLabelTable {
patch_id: number;
label_id: number;
}
export interface Database {
users: UserTable;
passkeys: PasskeyTable;
@@ -150,6 +168,9 @@ export interface Database {
ssh_keys: SshKeyTable;
releases: ReleaseTable;
release_assets: ReleaseAssetTable;
labels: LabelTable;
issue_labels: IssueLabelTable;
patch_labels: PatchLabelTable;
}
// Selectable row types (id is plain number, as returned by queries)
@@ -166,6 +187,7 @@ export type PatchReactionRow = Selectable<PatchReactionTable>;
export type SshKeyRow = Selectable<SshKeyTable>;
export type ReleaseRow = Selectable<ReleaseTable>;
export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
export type LabelRow = Selectable<LabelTable>;
const sqlite = new BunDatabase(DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
@@ -201,6 +223,26 @@ export const db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
// Migration: create labels tables if missing
sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#808080',
created_at TEXT NOT NULL,
UNIQUE(repo_id, name)
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, label_id)
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (patch_id, label_id)
)`);
export async function getRepo(name: string, isAdmin: boolean) {
const repo = await db
.selectFrom("repositories")
▾Msrc/db/schema.sql
@@ -136,3 +136,24 @@ CREATE TABLE IF NOT EXISTS release_assets (
content_type TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#808080',
created_at TEXT NOT NULL,
UNIQUE(repo_id, name)
);
CREATE TABLE IF NOT EXISTS issue_labels (
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, label_id)
);
CREATE TABLE IF NOT EXISTS patch_labels (
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (patch_id, label_id)
);
▾Asrc/lib/labelColor.ts
@@ -0,0 +1,7 @@
export function labelTextColor(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
▾Msrc/routes/issues.tsx
@@ -1,7 +1,7 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import {
requireAdmin,
requireAuth,
@@ -33,12 +33,44 @@ export const issueRoutes = new Elysia()
: ("open" as const);
const page = Math.max(1, query.page ?? 1);
const allCounts = await db
.selectFrom("issues")
.select(["status", db.fn.countAll<number>().as("count")])
// Parse label filter: query.labels may be a string or array of strings
const rawLabels = query.labels;
const labelIds: number[] = (
Array.isArray(rawLabels)
? rawLabels
: rawLabels
? [rawLabels]
: []
)
.map((v) => parseInt(v, 10))
.filter((n) => !isNaN(n));
const repoLabels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.groupBy("status")
.orderBy("name", "asc")
.execute();
let countQuery = db
.selectFrom("issues")
.select(["issues.status", db.fn.countAll<number>().as("count")])
.where("issues.repo_id", "=", repo.id);
if (labelIds.length > 0) {
countQuery = countQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef(
"issue_labels.issue_id",
"=",
"issues.id",
)
.where("issue_labels.label_id", "in", labelIds),
),
);
}
const allCounts = await countQuery.groupBy("issues.status").execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
@@ -49,7 +81,7 @@ export const issueRoutes = new Elysia()
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * ISSUES_PER_PAGE;
const issues = await db
let listQuery = db
.selectFrom("issues")
.leftJoin("users", "users.id", "issues.author_id")
.select([
@@ -67,16 +99,62 @@ export const issueRoutes = new Elysia()
"users.avatar_version as author_avatar_version",
])
.where("issues.repo_id", "=", repo.id)
.where("issues.status", "=", status)
.where("issues.status", "=", status);
if (labelIds.length > 0) {
listQuery = listQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef(
"issue_labels.issue_id",
"=",
"issues.id",
)
.where("issue_labels.label_id", "in", labelIds),
),
);
}
const issues = await listQuery
.orderBy("issues.number", "desc")
.limit(ISSUES_PER_PAGE)
.offset(offset)
.execute();
// Batch-fetch labels for displayed issues
const issueIds = issues.map((i) => i.id);
const issueLabelsRows =
issueIds.length > 0
? await db
.selectFrom("issue_labels")
.innerJoin(
"labels",
"labels.id",
"issue_labels.label_id",
)
.select([
"issue_labels.issue_id",
"labels.id",
"labels.name",
"labels.color",
])
.where("issue_labels.issue_id", "in", issueIds)
.execute()
: [];
const labelsByIssueId = new Map<number, LabelRow[]>();
for (const row of issueLabelsRows) {
const list = labelsByIssueId.get(row.issue_id) ?? [];
list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" });
labelsByIssueId.set(row.issue_id, list);
}
const labelsParam =
labelIds.length > 0
? `&labels=${labelIds.map(String).join(",")}`
: "";
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`,
pageUrlTemplate: `/${repo.name}/issues?status=${status}${labelsParam}&page={page}`,
};
return html(
<IssueList
@@ -91,6 +169,9 @@ export const issueRoutes = new Elysia()
status={status}
counts={counts}
pagination={pagination}
repoLabels={repoLabels}
selectedLabelIds={labelIds}
labelsByIssueId={labelsByIssueId}
/>,
);
},
@@ -98,6 +179,7 @@ export const issueRoutes = new Elysia()
query: t.Object({
status: t.Optional(t.String()),
page: t.Optional(t.Numeric()),
labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
}),
},
)
@@ -241,6 +323,20 @@ export const issueRoutes = new Elysia()
]),
);
const issueLabels = await db
.selectFrom("issue_labels")
.innerJoin("labels", "labels.id", "issue_labels.label_id")
.select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"])
.where("issue_labels.issue_id", "=", issue.id)
.execute();
const repoLabels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
return html(
<IssueDetail
user={user}
@@ -260,6 +356,8 @@ export const issueRoutes = new Elysia()
}
reactions={reactions}
commentReactions={commentReactions}
issueLabels={issueLabels}
repoLabels={repoLabels}
/>,
);
})
@@ -583,4 +681,87 @@ export const issueRoutes = new Elysia()
}),
body: t.Object({ edit_body: t.String() }),
},
)
.post(
"/:repo/issues/:number/labels/add",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
const label = await db
.selectFrom("labels")
.select(["id"])
.where("id", "=", body.label_id)
.where("repo_id", "=", repo.id)
.executeTakeFirst();
if (!label) {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
}
await db
.insertInto("issue_labels")
.values({ issue_id: issue.id, label_id: label.id })
.onConflict((oc) => oc.doNothing())
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
params: t.Object({ repo: t.String(), number: t.String() }),
body: t.Object({ label_id: t.Numeric() }),
},
)
.post(
"/:repo/issues/:number/labels/remove",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
await db
.deleteFrom("issue_labels")
.where("issue_id", "=", issue.id)
.where("label_id", "=", body.label_id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
params: t.Object({ repo: t.String(), number: t.String() }),
body: t.Object({ label_id: t.Numeric() }),
},
);
▾Msrc/routes/patches.tsx
@@ -6,7 +6,7 @@ import {
MAX_USER_UPLOAD_BYTES,
} from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import {
requireAdmin,
requireAuth,
@@ -66,12 +66,44 @@ export const patchRoutes = new Elysia()
: "open";
const page = Math.max(1, query.page ?? 1);
const allCounts = await db
.selectFrom("patches")
.select(["status", db.fn.countAll<number>().as("count")])
// Parse label filter
const rawLabels = query.labels;
const labelIds: number[] = (
Array.isArray(rawLabels)
? rawLabels
: rawLabels
? [rawLabels]
: []
)
.map((v) => parseInt(v, 10))
.filter((n) => !isNaN(n));
const repoLabels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.groupBy("status")
.orderBy("name", "asc")
.execute();
let countQuery = db
.selectFrom("patches")
.select(["patches.status", db.fn.countAll<number>().as("count")])
.where("patches.repo_id", "=", repo.id);
if (labelIds.length > 0) {
countQuery = countQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("patch_labels")
.select("patch_labels.patch_id")
.whereRef(
"patch_labels.patch_id",
"=",
"patches.id",
)
.where("patch_labels.label_id", "in", labelIds),
),
);
}
const allCounts = await countQuery.groupBy("patches.status").execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
@@ -82,7 +114,7 @@ export const patchRoutes = new Elysia()
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * PATCHES_PER_PAGE;
const patches = await db
let listQuery = db
.selectFrom("patches")
.leftJoin("users", "users.id", "patches.author_id")
.select([
@@ -104,16 +136,62 @@ export const patchRoutes = new Elysia()
"users.avatar_version as author_avatar_version",
])
.where("patches.repo_id", "=", repo.id)
.where("patches.status", "=", status)
.where("patches.status", "=", status);
if (labelIds.length > 0) {
listQuery = listQuery.where(({ exists, selectFrom }) =>
exists(
selectFrom("patch_labels")
.select("patch_labels.patch_id")
.whereRef(
"patch_labels.patch_id",
"=",
"patches.id",
)
.where("patch_labels.label_id", "in", labelIds),
),
);
}
const patches = await listQuery
.orderBy("patches.number", "desc")
.limit(PATCHES_PER_PAGE)
.offset(offset)
.execute();
// Batch-fetch labels for displayed patches
const patchIds = patches.map((p) => p.id);
const patchLabelsRows =
patchIds.length > 0
? await db
.selectFrom("patch_labels")
.innerJoin(
"labels",
"labels.id",
"patch_labels.label_id",
)
.select([
"patch_labels.patch_id",
"labels.id",
"labels.name",
"labels.color",
])
.where("patch_labels.patch_id", "in", patchIds)
.execute()
: [];
const labelsByPatchId = new Map<number, LabelRow[]>();
for (const row of patchLabelsRows) {
const list = labelsByPatchId.get(row.patch_id) ?? [];
list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" });
labelsByPatchId.set(row.patch_id, list);
}
const labelsParam =
labelIds.length > 0
? `&labels=${labelIds.map(String).join(",")}`
: "";
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
pageUrlTemplate: `/${repo.name}/patches?status=${status}${labelsParam}&page={page}`,
};
return html(
<PatchList
@@ -128,6 +206,9 @@ export const patchRoutes = new Elysia()
status={status}
counts={counts}
pagination={pagination}
repoLabels={repoLabels}
selectedLabelIds={labelIds}
labelsByPatchId={labelsByPatchId}
/>,
);
},
@@ -135,6 +216,7 @@ export const patchRoutes = new Elysia()
query: t.Object({
status: t.Optional(t.String()),
page: t.Optional(t.Numeric()),
labels: t.Optional(t.Union([t.String(), t.Array(t.String())])),
}),
},
)
@@ -386,6 +468,20 @@ export const patchRoutes = new Elysia()
const patchMeta = extractPatchMeta(patch.patch_content);
const patchLabels = await db
.selectFrom("patch_labels")
.innerJoin("labels", "labels.id", "patch_labels.label_id")
.select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"])
.where("patch_labels.patch_id", "=", patch.id)
.execute();
const repoLabels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
return html(
<PatchDetail
user={user}
@@ -411,6 +507,8 @@ export const patchRoutes = new Elysia()
}
reactions={reactions}
commentReactions={commentReactions}
patchLabels={patchLabels}
repoLabels={repoLabels}
/>,
);
},
@@ -861,4 +959,87 @@ export const patchRoutes = new Elysia()
edit_description: t.Optional(t.String()),
}),
},
)
.post(
"/:repo/patches/:number/labels/add",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const label = await db
.selectFrom("labels")
.select(["id"])
.where("id", "=", body.label_id)
.where("repo_id", "=", repo.id)
.executeTakeFirst();
if (!label) {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
}
await db
.insertInto("patch_labels")
.values({ patch_id: patch.id, label_id: label.id })
.onConflict((oc) => oc.doNothing())
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
params: t.Object({ repo: t.String(), number: t.String() }),
body: t.Object({ label_id: t.Numeric() }),
},
)
.post(
"/:repo/patches/:number/labels/remove",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
await db
.deleteFrom("patch_labels")
.where("patch_id", "=", patch.id)
.where("label_id", "=", body.label_id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
params: t.Object({ repo: t.String(), number: t.String() }),
body: t.Object({ label_id: t.Numeric() }),
},
);
▾Msrc/routes/repos.tsx
@@ -9,7 +9,7 @@ import {
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { db } from "../db/index.ts";
import { db, type LabelRow } from "../db/index.ts";
import { redirect } from "../lib/redirect.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
@@ -698,15 +698,30 @@ export const repoRoutes = new Elysia()
);
})
.get("/:repo/settings", async ({ params, cookie }) => {
.get("/:repo/settings", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const branches = await git.branches(repo.name);
const labels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
const success = typeof query.success === "string" ? query.success : undefined;
const error = typeof query.error === "string" ? query.error : undefined;
return html(
<RepoSettings user={user!} repo={repo} branches={branches} />,
<RepoSettings
user={user!}
repo={repo}
branches={branches}
labels={labels}
success={success}
error={error}
/>,
);
})
@@ -733,13 +748,8 @@ export const repoRoutes = new Elysia()
// Validate the selected branch exists (only if repo has commits)
if (branches.length > 0 && !branches.includes(newBranch)) {
return html(
<RepoSettings
user={user!}
repo={repo}
branches={branches}
error={`Branch "${newBranch}" does not exist.`}
/>,
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`,
);
}
@@ -761,19 +771,7 @@ export const repoRoutes = new Elysia()
await git.setHead(repo.name, newBranch).catch(() => {});
}
const updated = await db
.selectFrom("repositories")
.selectAll()
.where("id", "=", repo.id)
.executeTakeFirstOrThrow();
return html(
<RepoSettings
user={user!}
repo={updated}
branches={branches}
success="Settings saved."
/>,
);
return redirect(`/${repo.name}/settings?success=Settings+saved.`);
},
{
body: t.Object({
@@ -800,4 +798,83 @@ export const repoRoutes = new Elysia()
await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
return new Response(null, { status: 302, headers: { Location: "/" } });
});
})
.post(
"/:repo/settings/labels",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const name = body.name?.trim();
const color = body.color?.trim();
if (!name || name.length > 50) {
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`,
);
}
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`,
);
}
try {
await db
.insertInto("labels")
.values({
repo_id: repo.id,
name,
color,
created_at: new Date().toISOString(),
})
.execute();
} catch {
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`,
);
}
return redirect(`/${repo.name}/settings?success=Label+created.`);
},
{
body: t.Object({
name: t.String(),
color: t.String(),
}),
},
)
.post(
"/:repo/settings/labels/delete",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const label = await db
.selectFrom("labels")
.select(["id", "repo_id"])
.where("id", "=", body.id)
.executeTakeFirst();
if (!label || label.repo_id !== repo.id) {
return redirect(
`/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`,
);
}
await db.deleteFrom("labels").where("id", "=", body.id).execute();
return redirect(`/${repo.name}/settings?success=Label+deleted.`);
},
{
body: t.Object({ id: t.Numeric() }),
},
);
▾Msrc/styles/main.css
@@ -2719,3 +2719,174 @@
color: var(--color-text-muted);
font-size: var(--text-xs);
}
/* Labels */
.label-badge {
display: inline-block;
padding: 0 var(--space-2);
border-radius: 2em;
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.6;
word-break: break-all;
}
.issue-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.issue-labels-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0 var(--space-3);
}
.label-badge-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.label-remove-form {
display: inline;
}
.label-remove-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
font-size: var(--text-sm);
line-height: 1;
color: var(--color-text-muted);
opacity: 0.7;
}
.label-remove-btn:hover {
opacity: 1;
}
.label-add-inline-form {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.label-select {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
height: 2rem;
}
.label-settings-list {
display: flex;
flex-direction: column;
margin-bottom: var(--space-4);
max-height: 24rem;
overflow-y: auto;
}
.label-settings-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.label-settings-item:last-child {
border-bottom: none;
}
.label-settings-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.15);
}
.label-settings-name {
flex: 1;
font-weight: 500;
}
.label-add-form {
display: flex;
align-items: center;
gap: var(--space-2);
}
.label-name-input {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
height: 2rem;
box-sizing: border-box;
}
.label-name-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.label-color-swatch-label {
flex-shrink: 0;
cursor: pointer;
display: block;
line-height: 0;
}
.label-color-input {
display: block;
width: 2rem;
height: 2rem;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
cursor: pointer;
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
}
/* Label filter (details/summary popup) */
.list-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.label-filter {
position: relative;
}
.label-filter-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
z-index: 10;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.label-filter-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
max-height: 16rem;
overflow-y: auto;
}
.label-filter-item {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-sm);
}
.label-filter-actions {
display: flex;
gap: var(--space-2);
}
▾Msrc/views/issues/IssueDetail.tsx
@@ -1,8 +1,10 @@
import type {
IssueCommentRow,
IssueRow,
LabelRow,
RepositoryRow,
} from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Avatar } from "../Avatar.tsx";
@@ -27,6 +29,8 @@ interface IssueDetailProps {
})[];
reactions: ReactionCount[];
commentReactions: Map<number, ReactionCount[]>;
issueLabels: LabelRow[];
repoLabels: LabelRow[];
}
export function IssueDetail({
@@ -37,6 +41,8 @@ export function IssueDetail({
comments,
reactions,
commentReactions,
issueLabels,
repoLabels,
}: IssueDetailProps) {
const canEditIssue =
user != null &&
@@ -158,6 +164,75 @@ export function IssueDetail({
)}
</div>
{(issueLabels.length > 0 || user?.isAdmin) && (
<div class="issue-labels-row">
{issueLabels.map((label) => (
<span class="label-badge-wrap">
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
{user?.isAdmin && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/labels/remove`}
class="label-remove-form"
>
<input
type="hidden"
name="label_id"
value={String(label.id)}
/>
<button
type="submit"
class="label-remove-btn"
title="Remove label"
>
×
</button>
</form>
)}
</span>
))}
{user?.isAdmin &&
repoLabels.filter(
(l) =>
!issueLabels.some((il) => il.id === l.id),
).length > 0 && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/labels/add`}
class="label-add-inline-form"
>
<select name="label_id" class="label-select">
{repoLabels
.filter(
(l) =>
!issueLabels.some(
(il) => il.id === l.id,
),
)
.map((label) => (
<option
value={String(label.id)}
>
{label.name}
</option>
))}
</select>
<button
type="submit"
class="btn btn-sm btn-secondary"
>
Add label
</button>
</form>
)}
</div>
)}
<div class="timeline-item">
<div class="timeline-author">
<Avatar
▾Msrc/views/issues/IssueList.tsx
@@ -1,5 +1,6 @@
import type { IssueRow, RepositoryRow } from "../../db/index.ts";
import type { IssueRow, LabelRow, RepositoryRow } from "../../db/index.ts";
import { formatDate } from "../../lib/formatDate.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Avatar } from "../Avatar.tsx";
@@ -18,6 +19,9 @@ interface IssueListProps {
status: "open" | "closed" | "completed";
counts: Record<string, number>;
pagination: PaginationInfo;
repoLabels: LabelRow[];
selectedLabelIds: number[];
labelsByIssueId: Map<number, LabelRow[]>;
}
export function IssueList({
@@ -27,7 +31,14 @@ export function IssueList({
status,
counts,
pagination,
repoLabels,
selectedLabelIds,
labelsByIssueId,
}: IssueListProps) {
const labelsParam =
selectedLabelIds.length > 0
? `&labels=${selectedLabelIds.map(String).join(",")}`
: "";
return (
<Layout user={user} title={`Issues — ${repo.name}`}>
<div class="container">
@@ -36,7 +47,7 @@ export function IssueList({
<div class="list-header">
<div class="list-header-tabs">
<a
href={`/${repo.name}/issues?status=open`}
href={`/${repo.name}/issues?status=open${labelsParam}`}
class={`list-tab${status === "open" ? " active" : ""}`}
>
Open{" "}
@@ -45,7 +56,7 @@ export function IssueList({
)}
</a>
<a
href={`/${repo.name}/issues?status=completed`}
href={`/${repo.name}/issues?status=completed${labelsParam}`}
class={`list-tab${status === "completed" ? " active" : ""}`}
>
Completed{" "}
@@ -56,7 +67,7 @@ export function IssueList({
)}
</a>
<a
href={`/${repo.name}/issues?status=closed`}
href={`/${repo.name}/issues?status=closed${labelsParam}`}
class={`list-tab${status === "closed" ? " active" : ""}`}
>
Closed{" "}
@@ -65,14 +76,78 @@ export function IssueList({
)}
</a>
</div>
{user && (
<a
href={`/${repo.name}/issues/new`}
class="btn btn-primary btn-sm"
>
New issue
</a>
)}
<div class="list-header-actions">
{repoLabels.length > 0 && (
<details class="label-filter">
<summary class="btn btn-secondary btn-sm">
Filter by label
{selectedLabelIds.length > 0 && (
<span class="tab-count">
{selectedLabelIds.length}
</span>
)}
</summary>
<div class="label-filter-popup">
<form
method="GET"
action={`/${repo.name}/issues`}
>
<input
type="hidden"
name="status"
value={status}
/>
<div class="label-filter-list">
{repoLabels.map((label) => (
<label class="label-filter-item">
<input
type="checkbox"
name="labels"
value={String(label.id)}
checked={
selectedLabelIds.includes(
label.id,
)
? true
: undefined
}
/>
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
</label>
))}
</div>
<div class="label-filter-actions">
<button
type="submit"
class="btn btn-primary btn-sm"
>
Apply
</button>
<a
href={`/${repo.name}/issues?status=${status}`}
class="btn btn-secondary btn-sm"
>
Clear
</a>
</div>
</form>
</div>
</details>
)}
{user && (
<a
href={`/${repo.name}/issues/new`}
class="btn btn-primary btn-sm"
>
New issue
</a>
)}
</div>
</div>
{issues.length === 0 ? (
<div class="empty-state">
@@ -80,38 +155,53 @@ export function IssueList({
</div>
) : (
<ul class="issue-list">
{issues.map((issue) => (
<li class="issue-item">
<div class="issue-main">
<span
class={`issue-status-dot ${issue.status}`}
/>
<a
href={`/${repo.name}/issues/${issue.number}`}
class="issue-title"
>
{issue.title}
</a>
<span class="issue-number">
#{issue.number}
</span>
</div>
<div class="issue-meta">
<Avatar
userId={issue.author_id}
version={issue.author_avatar_version}
size={20}
/>
<span class="issue-author">
opened by{" "}
{displayName(issue.author_username)}
</span>
<time datetime={issue.created_at}>
{formatDate(issue.created_at)}
</time>
</div>
</li>
))}
{issues.map((issue) => {
const labels = labelsByIssueId.get(issue.id) ?? [];
return (
<li class="issue-item">
<div class="issue-main">
<span
class={`issue-status-dot ${issue.status}`}
/>
<a
href={`/${repo.name}/issues/${issue.number}`}
class="issue-title"
>
{issue.title}
</a>
<span class="issue-number">
#{issue.number}
</span>
</div>
{labels.length > 0 && (
<div class="issue-labels">
{labels.map((label) => (
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
))}
</div>
)}
<div class="issue-meta">
<Avatar
userId={issue.author_id}
version={issue.author_avatar_version}
size={20}
/>
<span class="issue-author">
opened by{" "}
{displayName(issue.author_username)}
</span>
<time datetime={issue.created_at}>
{formatDate(issue.created_at)}
</time>
</div>
</li>
);
})}
</ul>
)}
<Pagination {...pagination} />
▾Msrc/views/patches/PatchDetail.tsx
@@ -1,10 +1,12 @@
import { escapeHtml } from "@kitajs/html";
import type {
LabelRow,
PatchCommentRow,
PatchRow,
RepositoryRow,
} from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
@@ -39,6 +41,8 @@ interface PatchDetailProps {
})[];
reactions: ReactionCount[];
commentReactions: Map<number, ReactionCount[]>;
patchLabels: LabelRow[];
repoLabels: LabelRow[];
}
export function PatchDetail({
@@ -53,6 +57,8 @@ export function PatchDetail({
comments,
reactions,
commentReactions,
patchLabels,
repoLabels,
}: PatchDetailProps) {
const canEdit =
user != null &&
@@ -214,6 +220,74 @@ export function PatchDetail({
</div>
)}
</div>
{(patchLabels.length > 0 || user?.isAdmin) && (
<div class="issue-labels-row">
{patchLabels.map((label) => (
<span class="label-badge-wrap">
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
{user?.isAdmin && (
<form
method="POST"
action={`${baseUrl}/labels/remove`}
class="label-remove-form"
>
<input
type="hidden"
name="label_id"
value={String(label.id)}
/>
<button
type="submit"
class="label-remove-btn"
title="Remove label"
>
×
</button>
</form>
)}
</span>
))}
{user?.isAdmin &&
repoLabels.filter(
(l) =>
!patchLabels.some((pl) => pl.id === l.id),
).length > 0 && (
<form
method="POST"
action={`${baseUrl}/labels/add`}
class="label-add-inline-form"
>
<select name="label_id" class="label-select">
{repoLabels
.filter(
(l) =>
!patchLabels.some(
(pl) => pl.id === l.id,
),
)
.map((label) => (
<option
value={String(label.id)}
>
{label.name}
</option>
))}
</select>
<button
type="submit"
class="btn btn-sm btn-secondary"
>
Add label
</button>
</form>
)}
</div>
)}
</div>
{/* Subview tabs */}
▾Msrc/views/patches/PatchList.tsx
@@ -1,5 +1,6 @@
import type { PatchRow, RepositoryRow } from "../../db/index.ts";
import type { LabelRow, PatchRow, RepositoryRow } from "../../db/index.ts";
import { formatDate } from "../../lib/formatDate.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Avatar } from "../Avatar.tsx";
@@ -18,6 +19,9 @@ interface PatchListProps {
status: string;
counts: Record<string, number>;
pagination: PaginationInfo;
repoLabels: LabelRow[];
selectedLabelIds: number[];
labelsByPatchId: Map<number, LabelRow[]>;
}
export function PatchList({
@@ -27,7 +31,14 @@ export function PatchList({
status,
counts,
pagination,
repoLabels,
selectedLabelIds,
labelsByPatchId,
}: PatchListProps) {
const labelsParam =
selectedLabelIds.length > 0
? `&labels=${selectedLabelIds.map(String).join(",")}`
: "";
return (
<Layout user={user} title={`Patches — ${repo.name}`}>
<div class="container">
@@ -36,7 +47,7 @@ export function PatchList({
<div class="list-header">
<div class="list-header-tabs">
<a
href={`/${repo.name}/patches?status=open`}
href={`/${repo.name}/patches?status=open${labelsParam}`}
class={`list-tab${status === "open" ? " active" : ""}`}
>
Open{" "}
@@ -45,7 +56,7 @@ export function PatchList({
)}
</a>
<a
href={`/${repo.name}/patches?status=merged`}
href={`/${repo.name}/patches?status=merged${labelsParam}`}
class={`list-tab${status === "merged" ? " active" : ""}`}
>
Merged{" "}
@@ -54,7 +65,7 @@ export function PatchList({
)}
</a>
<a
href={`/${repo.name}/patches?status=closed`}
href={`/${repo.name}/patches?status=closed${labelsParam}`}
class={`list-tab${status === "closed" ? " active" : ""}`}
>
Closed{" "}
@@ -63,14 +74,78 @@ export function PatchList({
)}
</a>
</div>
{user && (
<a
href={`/${repo.name}/patches/new`}
class="btn btn-primary btn-sm"
>
Upload patch
</a>
)}
<div class="list-header-actions">
{repoLabels.length > 0 && (
<details class="label-filter">
<summary class="btn btn-secondary btn-sm">
Filter by label
{selectedLabelIds.length > 0 && (
<span class="tab-count">
{selectedLabelIds.length}
</span>
)}
</summary>
<div class="label-filter-popup">
<form
method="GET"
action={`/${repo.name}/patches`}
>
<input
type="hidden"
name="status"
value={status}
/>
<div class="label-filter-list">
{repoLabels.map((label) => (
<label class="label-filter-item">
<input
type="checkbox"
name="labels"
value={String(label.id)}
checked={
selectedLabelIds.includes(
label.id,
)
? true
: undefined
}
/>
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
</label>
))}
</div>
<div class="label-filter-actions">
<button
type="submit"
class="btn btn-primary btn-sm"
>
Apply
</button>
<a
href={`/${repo.name}/patches?status=${status}`}
class="btn btn-secondary btn-sm"
>
Clear
</a>
</div>
</form>
</div>
</details>
)}
{user && (
<a
href={`/${repo.name}/patches/new`}
class="btn btn-primary btn-sm"
>
Upload patch
</a>
)}
</div>
</div>
{patches.length === 0 ? (
<div class="empty-state">
@@ -78,37 +153,52 @@ export function PatchList({
</div>
) : (
<ul class="issue-list">
{patches.map((patch) => (
<li class="issue-item">
<div class="issue-main">
<span
class={`patch-status-dot ${patch.status}`}
/>
<a
href={`/${repo.name}/patches/${patch.number}`}
class="issue-title"
>
{patch.title}
</a>
<span class="issue-number">
#{patch.number}
</span>
</div>
<div class="issue-meta">
<Avatar
userId={patch.author_id}
version={patch.author_avatar_version}
size={20}
/>
<span>
by {displayName(patch.author_username)}
</span>
<time datetime={patch.created_at}>
{formatDate(patch.created_at)}
</time>
</div>
</li>
))}
{patches.map((patch) => {
const labels = labelsByPatchId.get(patch.id) ?? [];
return (
<li class="issue-item">
<div class="issue-main">
<span
class={`patch-status-dot ${patch.status}`}
/>
<a
href={`/${repo.name}/patches/${patch.number}`}
class="issue-title"
>
{patch.title}
</a>
<span class="issue-number">
#{patch.number}
</span>
</div>
{labels.length > 0 && (
<div class="issue-labels">
{labels.map((label) => (
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
))}
</div>
)}
<div class="issue-meta">
<Avatar
userId={patch.author_id}
version={patch.author_avatar_version}
size={20}
/>
<span>
by {displayName(patch.author_username)}
</span>
<time datetime={patch.created_at}>
{formatDate(patch.created_at)}
</time>
</div>
</li>
);
})}
</ul>
)}
<Pagination {...pagination} />
▾Msrc/views/repos/RepoSettings.tsx
@@ -1,4 +1,4 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
@@ -8,6 +8,7 @@ interface RepoSettingsProps {
user: SessionUser;
repo: RepositoryRow;
branches: string[];
labels: LabelRow[];
success?: string;
error?: string;
}
@@ -16,6 +17,7 @@ export function RepoSettings({
user,
repo,
branches,
labels,
success,
error,
}: RepoSettingsProps) {
@@ -129,6 +131,65 @@ export function RepoSettings({
Save settings
</button>
</form>
<div class="form-card">
<h2 class="section-title">Labels</h2>
{labels.length > 0 && (
<div class="label-settings-list">
{labels.map((label) => (
<div class="label-settings-item">
<span
class="label-settings-swatch"
style={`background:${label.color}`}
/>
<span class="label-settings-name">
{label.name}
</span>
<form
method="POST"
action={`/${repo.name}/settings/labels/delete`}
>
<input
type="hidden"
name="id"
value={String(label.id)}
/>
<button
class="btn btn-danger btn-sm"
type="submit"
>
Delete
</button>
</form>
</div>
))}
</div>
)}
<form
method="POST"
action={`/${repo.name}/settings/labels`}
class="label-add-form"
>
<input
class="form-input label-name-input"
type="text"
name="name"
placeholder="Label name"
maxlength="50"
required
/>
<label class="label-color-swatch-label" title="Pick a color">
<input
type="color"
name="color"
value="#808080"
class="label-color-input"
/>
</label>
<button type="submit" class="btn btn-secondary btn-sm">
Add label
</button>
</form>
</div>
<div class="danger-zone">
<h2 class="section-title danger-title">Danger zone</h2>
<div class="form-card danger-card">
▾Mtests/e2e.test.ts
@@ -2888,3 +2888,259 @@ describe('file editing', () => {
} finally { await page.close(); }
});
});
// ─── Labels ───────────────────────────────────────────────────────────────────
describe('labels', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
let patchUrl: string;
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add label-test.txt',
'',
'---',
'diff --git a/label-test.txt b/label-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/label-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a dedicated repo for label tests
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'label-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/label-repo`);
} finally { await page.close(); }
// Create an issue
const issuePage = await adminCtx.newPage();
try {
await issuePage.goto(`${BASE}/label-repo/issues/new`);
await issuePage.fill('[name=title]', 'Labelled issue');
await issuePage.click('form[action$="/issues"] button[type=submit]');
await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
issueUrl = issuePage.url();
} finally { await issuePage.close(); }
// Create a patch
writeTempFile('/tmp/label-test.patch', VALID_PATCH);
const patchPage = await adminCtx.newPage();
try {
await patchPage.goto(`${BASE}/label-repo/patches/new`);
await patchPage.fill('[name=title]', 'Labelled patch');
await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
await patchPage.click('form[action$="/patches"] button[type=submit]');
await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
patchUrl = patchPage.url();
} finally { await patchPage.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('create label in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#ff0000'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('create a second label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'enhancement');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#00aa00'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).toContain('bug');
expect(badges).toContain('enhancement');
} finally { await page.close(); }
});
test('duplicate label name is rejected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
await page.fill('input[name=name]', 'bug');
await page.locator('input[type=color][name=color]').evaluate(
(el: any) => { el.value = '#0000ff'; },
);
await page.click('form[action$="/settings/labels"] button[type=submit]');
await page.waitForURL(/\/label-repo\/settings/);
expect(await page.locator('.form-error').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('non-admin cannot create labels', async () => {
const ctx = await browser.newContext();
try {
const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
form: { name: 'nope', color: '#123456' },
maxRedirects: 0,
});
// Unauthenticated → redirected to /login
expect(r.status()).toBe(302);
expect(r.headers()['location']).toContain('/login');
} finally { await ctx.close(); }
});
test('assign label to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('label appears on issue list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('filter issues by label shows only matching issues', async () => {
// Create a second issue without the label
const createPage = await adminCtx.newPage();
try {
await createPage.goto(`${BASE}/label-repo/issues/new`);
await createPage.fill('[name=title]', 'Unlabelled issue');
await createPage.click('form[action$="/issues"] button[type=submit]');
await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
} finally { await createPage.close(); }
// Open filter popup and apply label filter
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
// Get the label id from the checkbox
const checkbox = page.locator('.label-filter-item input[name=labels]').first();
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
// Navigate with the filter applied via URL
await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
} finally { await page.close(); }
});
test('filter popup is visible without JS', async () => {
// details/summary is a native HTML element — verify it renders
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
expect(await page.locator('details.label-filter').isVisible()).toBe(true);
expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('remove label from issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Label badge should no longer appear in the labels row
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('bug');
} finally { await page.close(); }
});
test('assign label to patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.selectOption('select[name=label_id]', { label: 'enhancement' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('label appears on patch list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
} finally { await page.close(); }
});
test('filter patches by label', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/patches`);
// Find the checkbox for the 'enhancement' label specifically
const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
.locator('input[name=labels]');
const labelId = await checkbox.getAttribute('value');
expect(labelId).toBeTruthy();
await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
} finally { await page.close(); }
});
test('remove label from patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(patchUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(labelBadges).not.toContain('enhancement');
} finally { await page.close(); }
});
test('delete label removes it from settings list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/settings`);
const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
await bugItem.locator('form[action$="/labels/delete"] button').click();
await page.waitForURL(/\/label-repo\/settings/);
const badges = await page.locator('.label-settings-name').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('deleted label no longer appears in filter popup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/label-repo/issues`);
const filterLabels = await page.locator('.label-filter-item').allTextContents();
expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
} finally { await page.close(); }
});
});