optionally allow users to set labels on their own issues/patches
Msrc/db/index.ts
@@ -42,6 +42,7 @@ interface RepositoryTable {
patch_seq: Generated<number>;
issue_template: string | null;
patch_template: string | null;
allow_user_labels: Generated<number>;
}
interface IssueTable {
@@ -223,6 +224,16 @@ export const db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
// Migration: add allow_user_labels column to repositories if missing
const repoCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(repositories)")
.all();
if (!repoCols.some((c) => c.name === "allow_user_labels")) {
sqlite.run(
"ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
);
}
// Migration: create labels tables if missing
sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Msrc/db/schema.sql
@@ -34,10 +34,11 @@ CREATE TABLE IF NOT EXISTS repositories (
is_pinned INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL,
issue_seq INTEGER NOT NULL DEFAULT 0,
patch_seq INTEGER NOT NULL DEFAULT 0,
issue_template TEXT,
patch_template TEXT
issue_seq INTEGER NOT NULL DEFAULT 0,
patch_seq INTEGER NOT NULL DEFAULT 0,
issue_template TEXT,
patch_template TEXT,
allow_user_labels INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issues (
Msrc/routes/issues.tsx
@@ -43,7 +43,7 @@ export const issueRoutes = new Elysia()
: []
)
.map((v) => parseInt(v, 10))
.filter((n) => !isNaN(n));
.filter((n) => !Number.isNaN(n));
const repoLabels = await db
.selectFrom("labels")
@@ -61,16 +61,14 @@ export const issueRoutes = new Elysia()
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef(
"issue_labels.issue_id",
"=",
"issues.id",
)
.whereRef("issue_labels.issue_id", "=", "issues.id")
.where("issue_labels.label_id", "in", labelIds),
),
);
}
const allCounts = await countQuery.groupBy("issues.status").execute();
const allCounts = await countQuery
.groupBy("issues.status")
.execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
@@ -105,11 +103,7 @@ export const issueRoutes = new Elysia()
exists(
selectFrom("issue_labels")
.select("issue_labels.issue_id")
.whereRef(
"issue_labels.issue_id",
"=",
"issues.id",
)
.whereRef("issue_labels.issue_id", "=", "issues.id")
.where("issue_labels.label_id", "in", labelIds),
),
);
@@ -143,7 +137,13 @@ export const issueRoutes = new Elysia()
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: "" });
list.push({
id: row.id,
repo_id: repo.id,
name: row.name,
color: row.color,
created_at: "",
});
labelsByIssueId.set(row.issue_id, list);
}
@@ -190,11 +190,18 @@ export const issueRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const labels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
return html(
<NewIssue
user={user!}
repo={repo}
template={repo.issue_template ?? undefined}
labels={labels}
/>,
);
})
@@ -210,15 +217,32 @@ export const issueRoutes = new Elysia()
const { title, body: issueBody } = body;
if (!title?.trim()) {
const labels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
return html(
<NewIssue
user={user!}
repo={repo}
error="Title is required"
labels={labels}
/>,
);
}
const rawIds =
user!.isAdmin || repo.allow_user_labels === 1
? body.label_ids
: undefined;
const labelIds = rawIds
? (Array.isArray(rawIds) ? rawIds : [rawIds])
.map(Number)
.filter(Boolean)
: [];
const now = new Date().toISOString();
const { number } = await db.transaction().execute(async (trx) => {
const { issue_seq } = await trx
@@ -227,7 +251,7 @@ export const issueRoutes = new Elysia()
.where("id", "=", repo.id)
.returning("issue_seq")
.executeTakeFirstOrThrow();
await trx
const inserted = await trx
.insertInto("issues")
.values({
repo_id: repo.id,
@@ -239,7 +263,28 @@ export const issueRoutes = new Elysia()
created_at: now,
updated_at: now,
})
.execute();
.returning("id")
.executeTakeFirstOrThrow();
if (labelIds.length > 0) {
const validLabels = await trx
.selectFrom("labels")
.select("id")
.where("repo_id", "=", repo.id)
.where("id", "in", labelIds)
.execute();
if (validLabels.length > 0) {
await trx
.insertInto("issue_labels")
.values(
validLabels.map((l) => ({
issue_id: inserted.id,
label_id: l.id,
})),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
}
return { number: issue_seq };
});
@@ -252,6 +297,9 @@ export const issueRoutes = new Elysia()
body: t.Object({
title: t.String(),
body: t.Optional(t.String()),
label_ids: t.Optional(
t.Union([t.String(), t.Array(t.String())]),
),
}),
},
)
@@ -326,7 +374,13 @@ 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"])
.select([
"labels.id",
"labels.repo_id",
"labels.name",
"labels.color",
"labels.created_at",
])
.where("issue_labels.issue_id", "=", issue.id)
.execute();
@@ -687,20 +741,24 @@ export const issueRoutes = new Elysia()
"/: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 (!user) return new Response("Unauthorized", { status: 401 });
const repo = await getRepo(params.repo, user.isAdmin);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id"])
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
const canManage =
user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === issue.author_id);
if (!canManage) return new Response("Forbidden", { status: 403 });
const label = await db
.selectFrom("labels")
.select(["id"])
@@ -735,20 +793,24 @@ export const issueRoutes = new Elysia()
"/: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 (!user) return new Response("Unauthorized", { status: 401 });
const repo = await getRepo(params.repo, user.isAdmin);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id"])
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
const canManage =
user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === issue.author_id);
if (!canManage) return new Response("Forbidden", { status: 403 });
await db
.deleteFrom("issue_labels")
.where("issue_id", "=", issue.id)
Msrc/routes/patches.tsx
@@ -76,7 +76,7 @@ export const patchRoutes = new Elysia()
: []
)
.map((v) => parseInt(v, 10))
.filter((n) => !isNaN(n));
.filter((n) => !Number.isNaN(n));
const repoLabels = await db
.selectFrom("labels")
@@ -87,7 +87,10 @@ export const patchRoutes = new Elysia()
let countQuery = db
.selectFrom("patches")
.select(["patches.status", db.fn.countAll<number>().as("count")])
.select([
"patches.status",
db.fn.countAll<number>().as("count"),
])
.where("patches.repo_id", "=", repo.id);
if (labelIds.length > 0) {
countQuery = countQuery.where(({ exists, selectFrom }) =>
@@ -103,7 +106,9 @@ export const patchRoutes = new Elysia()
),
);
}
const allCounts = await countQuery.groupBy("patches.status").execute();
const allCounts = await countQuery
.groupBy("patches.status")
.execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
@@ -180,7 +185,13 @@ export const patchRoutes = new Elysia()
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: "" });
list.push({
id: row.id,
repo_id: repo.id,
name: row.name,
color: row.color,
created_at: "",
});
labelsByPatchId.set(row.patch_id, list);
}
@@ -227,11 +238,18 @@ export const patchRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const labels = await db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
return html(
<NewPatch
user={user!}
repo={repo}
template={repo.patch_template ?? undefined}
labels={labels}
/>,
);
})
@@ -245,12 +263,21 @@ export const patchRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const getLabels = () =>
db
.selectFrom("labels")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
if (!body.title?.trim()) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Title is required"
labels={await getLabels()}
/>,
);
}
@@ -261,6 +288,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch file is required"
labels={await getLabels()}
/>,
);
}
@@ -271,6 +299,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch file is too large"
labels={await getLabels()}
/>,
);
}
@@ -282,6 +311,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch file is empty"
labels={await getLabels()}
/>,
);
}
@@ -293,6 +323,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="File does not appear to be a valid patch file"
labels={await getLabels()}
/>,
);
}
@@ -304,6 +335,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch is missing a Subject header. Make sure to upload a patch created with git format-patch."
labels={await getLabels()}
/>,
);
}
@@ -313,6 +345,7 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch is missing a From header with name and email."
labels={await getLabels()}
/>,
);
}
@@ -322,9 +355,21 @@ export const patchRoutes = new Elysia()
user={user!}
repo={repo}
error="Patch is missing a Date header."
labels={await getLabels()}
/>,
);
}
const rawIds =
user!.isAdmin || repo.allow_user_labels === 1
? body.label_ids
: undefined;
const labelIds = rawIds
? (Array.isArray(rawIds) ? rawIds : [rawIds])
.map(Number)
.filter(Boolean)
: [];
const now = new Date().toISOString();
const { number, result } = await db
.transaction()
@@ -353,6 +398,26 @@ export const patchRoutes = new Elysia()
})
.returning("id")
.executeTakeFirstOrThrow();
if (labelIds.length > 0) {
const validLabels = await trx
.selectFrom("labels")
.select("id")
.where("repo_id", "=", repo.id)
.where("id", "in", labelIds)
.execute();
if (validLabels.length > 0) {
await trx
.insertInto("patch_labels")
.values(
validLabels.map((l) => ({
patch_id: inserted.id,
label_id: l.id,
})),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
}
return { number: patch_seq, result: inserted };
});
@@ -368,6 +433,9 @@ export const patchRoutes = new Elysia()
title: t.Optional(t.String()),
description: t.Optional(t.String()),
patch_file: t.Optional(t.File()),
label_ids: t.Optional(
t.Union([t.String(), t.Array(t.String())]),
),
}),
},
)
@@ -471,7 +539,13 @@ export const patchRoutes = new Elysia()
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"])
.select([
"labels.id",
"labels.repo_id",
"labels.name",
"labels.color",
"labels.created_at",
])
.where("patch_labels.patch_id", "=", patch.id)
.execute();
@@ -965,20 +1039,24 @@ export const patchRoutes = new Elysia()
"/: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 (!user) return new Response("Unauthorized", { status: 401 });
const repo = await getRepo(params.repo, user.isAdmin);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id"])
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const canManage =
user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === patch.author_id);
if (!canManage) return new Response("Forbidden", { status: 403 });
const label = await db
.selectFrom("labels")
.select(["id"])
@@ -1013,20 +1091,24 @@ export const patchRoutes = new Elysia()
"/: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 (!user) return new Response("Unauthorized", { status: 401 });
const repo = await getRepo(params.repo, user.isAdmin);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id"])
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const canManage =
user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === patch.author_id);
if (!canManage) return new Response("Forbidden", { status: 403 });
await db
.deleteFrom("patch_labels")
.where("patch_id", "=", patch.id)
Msrc/routes/repos.tsx
@@ -9,7 +9,7 @@ import {
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { db, type LabelRow } from "../db/index.ts";
import { db } from "../db/index.ts";
import { redirect } from "../lib/redirect.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
@@ -711,7 +711,8 @@ export const repoRoutes = new Elysia()
.where("repo_id", "=", repo.id)
.orderBy("name", "asc")
.execute();
const success = typeof query.success === "string" ? query.success : undefined;
const success =
typeof query.success === "string" ? query.success : undefined;
const error = typeof query.error === "string" ? query.error : undefined;
return html(
<RepoSettings
@@ -738,6 +739,7 @@ export const repoRoutes = new Elysia()
description,
is_private,
is_pinned,
allow_user_labels,
default_branch,
issue_template,
patch_template,
@@ -759,6 +761,7 @@ export const repoRoutes = new Elysia()
description: description?.trim() || null,
is_private: is_private === "1" ? 1 : 0,
is_pinned: is_pinned === "1" ? 1 : 0,
allow_user_labels: allow_user_labels === "1" ? 1 : 0,
default_branch: newBranch,
issue_template: issue_template?.trim() || null,
patch_template: patch_template?.trim() || null,
@@ -778,6 +781,7 @@ export const repoRoutes = new Elysia()
description: t.Optional(t.String()),
is_private: t.Optional(t.String()),
is_pinned: t.Optional(t.String()),
allow_user_labels: t.Optional(t.String()),
default_branch: t.Optional(t.String()),
issue_template: t.Optional(t.String()),
patch_template: t.Optional(t.String()),
Msrc/routes/settings.tsx
@@ -1,12 +1,12 @@
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { utils as sshUtils } from "ssh2";
import { REGISTRATION_TYPE } from "../config.ts";
import {
ADMIN_USERNAME,
VALID_KEY_TYPES,
VALID_USERNAME_RE,
} from "../constants.ts";
import { REGISTRATION_TYPE } from "../config.ts";
import { db } from "../db";
import { redirect } from "../lib/redirect.ts";
import { resolveSession } from "../middleware/session.ts";
Msrc/styles/main.css
@@ -376,7 +376,8 @@
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-group label {
.form-group label,
.form-group .form-group-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
@@ -2778,6 +2779,18 @@
color: var(--color-text);
height: 2rem;
}
.label-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.label-checkbox-item {
display: inline-flex;
align-items: center;
gap: var(--space-1);
cursor: pointer;
}
.label-settings-list {
display: flex;
flex-direction: column;
Msrc/views/Settings.tsx
@@ -405,7 +405,9 @@ export function Settings({
<li class="queue-item">
<div class="queue-item-meta">
<div class="queue-item-header">
<strong>{u.username}</strong>
<strong>
{u.username}
</strong>
<span class="queue-item-date">
{formatDateTime(
u.created_at,
Msrc/views/issues/IssueDetail.tsx
@@ -48,6 +48,10 @@ export function IssueDetail({
user != null &&
(user.isAdmin ||
(user.id === issue.author_id && issue.status === "open"));
const canManageLabels =
user != null &&
(user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === issue.author_id));
const canEditComment = (c: IssueCommentRow) =>
user != null &&
(user.isAdmin || (user.id === c.author_id && issue.status === "open"));
@@ -164,7 +168,7 @@ export function IssueDetail({
)}
</div>
{(issueLabels.length > 0 || user?.isAdmin) && (
{(issueLabels.length > 0 || canManageLabels) && (
<div class="issue-labels-row">
{issueLabels.map((label) => (
<span class="label-badge-wrap">
@@ -174,7 +178,7 @@ export function IssueDetail({
>
{label.name}
</span>
{user?.isAdmin && (
{canManageLabels && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/labels/remove`}
@@ -196,22 +200,28 @@ export function IssueDetail({
)}
</span>
))}
{user?.isAdmin &&
{canManageLabels &&
repoLabels.filter(
(l) =>
!issueLabels.some((il) => il.id === l.id),
!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">
<select
name="label_id"
class="label-select"
>
{repoLabels
.filter(
(l) =>
!issueLabels.some(
(il) => il.id === l.id,
(il) =>
il.id === l.id,
),
)
.map((label) => (
Msrc/views/issues/IssueList.tsx
@@ -188,7 +188,9 @@ export function IssueList({
<div class="issue-meta">
<Avatar
userId={issue.author_id}
version={issue.author_avatar_version}
version={
issue.author_avatar_version
}
size={20}
/>
<span class="issue-author">
Msrc/views/issues/NewIssue.tsx
@@ -1,4 +1,5 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -9,9 +10,16 @@ interface NewIssueProps {
repo: RepositoryRow;
error?: string;
template?: string;
labels: LabelRow[];
}
export function NewIssue({ user, repo, error, template }: NewIssueProps) {
export function NewIssue({
user,
repo,
error,
template,
labels,
}: NewIssueProps) {
return (
<Layout user={user} title={`New issue — ${repo.name}`}>
<div class="container">
@@ -50,6 +58,29 @@ export function NewIssue({ user, repo, error, template }: NewIssueProps) {
{template ?? ""}
</textarea>
</div>
{labels.length > 0 &&
(user.isAdmin || repo.allow_user_labels === 1) && (
<div class="form-group">
<span class="form-group-label">Labels</span>
<div class="label-checkbox-list">
{labels.map((label) => (
<label class="label-checkbox-item">
<input
type="checkbox"
name="label_ids"
value={String(label.id)}
/>
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
</label>
))}
</div>
</div>
)}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Submit issue
Msrc/views/patches/NewPatch.tsx
@@ -1,4 +1,5 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { LabelRow, RepositoryRow } from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -9,9 +10,16 @@ interface NewPatchProps {
repo: RepositoryRow;
error?: string;
template?: string;
labels: LabelRow[];
}
export function NewPatch({ user, repo, error, template }: NewPatchProps) {
export function NewPatch({
user,
repo,
error,
template,
labels,
}: NewPatchProps) {
return (
<Layout user={user} title={`New patch — ${repo.name}`}>
<div class="container">
@@ -58,6 +66,29 @@ export function NewPatch({ user, repo, error, template }: NewPatchProps) {
required
/>
</div>
{labels.length > 0 &&
(user.isAdmin || repo.allow_user_labels === 1) && (
<div class="form-group">
<span class="form-group-label">Labels</span>
<div class="label-checkbox-list">
{labels.map((label) => (
<label class="label-checkbox-item">
<input
type="checkbox"
name="label_ids"
value={String(label.id)}
/>
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
</label>
))}
</div>
</div>
)}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Upload patch
Msrc/views/patches/PatchDetail.tsx
@@ -64,6 +64,10 @@ export function PatchDetail({
user != null &&
(user.isAdmin ||
(user.id === patch.author_id && patch.status === "open"));
const canManageLabels =
user != null &&
(user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === patch.author_id));
const canEditComment = (c: PatchCommentRow) =>
user != null &&
(user.isAdmin || (user.id === c.author_id && patch.status === "open"));
@@ -220,7 +224,7 @@ export function PatchDetail({
</div>
)}
</div>
{(patchLabels.length > 0 || user?.isAdmin) && (
{(patchLabels.length > 0 || canManageLabels) && (
<div class="issue-labels-row">
{patchLabels.map((label) => (
<span class="label-badge-wrap">
@@ -230,7 +234,7 @@ export function PatchDetail({
>
{label.name}
</span>
{user?.isAdmin && (
{canManageLabels && (
<form
method="POST"
action={`${baseUrl}/labels/remove`}
@@ -252,22 +256,28 @@ export function PatchDetail({
)}
</span>
))}
{user?.isAdmin &&
{canManageLabels &&
repoLabels.filter(
(l) =>
!patchLabels.some((pl) => pl.id === l.id),
!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">
<select
name="label_id"
class="label-select"
>
{repoLabels
.filter(
(l) =>
!patchLabels.some(
(pl) => pl.id === l.id,
(pl) =>
pl.id === l.id,
),
)
.map((label) => (
Msrc/views/patches/PatchList.tsx
@@ -186,11 +186,14 @@ export function PatchList({
<div class="issue-meta">
<Avatar
userId={patch.author_id}
version={patch.author_avatar_version}
version={
patch.author_avatar_version
}
size={20}
/>
<span>
by {displayName(patch.author_username)}
by{" "}
{displayName(patch.author_username)}
</span>
<time datetime={patch.created_at}>
{formatDate(patch.created_at)}
Msrc/views/repos/RepoSettings.tsx
@@ -94,6 +94,18 @@ export function RepoSettings({
list)
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="allow_user_labels"
value="1"
checked={repo.allow_user_labels === 1}
/>
Allow users to add/remove labels on their own issues
and patches
</label>
</div>
<div class="form-group">
<label for="issue_template">
Issue template{" "}
@@ -177,7 +189,10 @@ export function RepoSettings({
maxlength="50"
required
/>
<label class="label-color-swatch-label" title="Pick a color">
<label
class="label-color-swatch-label"
title="Pick a color"
>
<input
type="color"
name="color"
Mtests/e2e.test.ts
@@ -3144,3 +3144,315 @@ describe('labels', () => {
} finally { await page.close(); }
});
});
// ─── User label management ────────────────────────────────────────────────────
describe('user label management', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// URL of an issue owned by alice
let aliceIssueUrl: string;
// URL of an issue owned by admin
let adminIssueUrl: string;
// label IDs, fetched from the filter inputs
let bugLabelId: 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 ulm-test.txt',
'',
'---',
'diff --git a/ulm-test.txt b/ulm-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/ulm-test.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create dedicated repo
const repoPage = await adminCtx.newPage();
try {
await repoPage.goto(`${BASE}/new`);
await repoPage.fill('[name=name]', 'ulm-repo');
await repoPage.click('form[action="/new"] button[type=submit]');
await repoPage.waitForURL(`${BASE}/ulm-repo`);
} finally { await repoPage.close(); }
// Create labels 'bug' and 'feature'
for (const name of ['bug', 'feature']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/ulm-repo/settings`);
await p.fill('input[name=name]', name);
await p.click('form[action$="/settings/labels"] button[type=submit]');
await p.waitForURL(/\/ulm-repo\/settings/);
} finally { await p.close(); }
}
// Create an issue owned by admin
const adminIssuePage = await adminCtx.newPage();
try {
await adminIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await adminIssuePage.fill('[name=title]', "Admin's issue");
await adminIssuePage.click('form[action$="/issues"] button[type=submit]');
await adminIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
adminIssueUrl = adminIssuePage.url();
} finally { await adminIssuePage.close(); }
// Create an issue owned by alice
const aliceIssuePage = await aliceCtx.newPage();
try {
await aliceIssuePage.goto(`${BASE}/ulm-repo/issues/new`);
await aliceIssuePage.fill('[name=title]', "Alice's issue");
await aliceIssuePage.click('form[action$="/issues"] button[type=submit]');
await aliceIssuePage.waitForURL(/\/ulm-repo\/issues\/\d+/);
aliceIssueUrl = aliceIssuePage.url();
} finally { await aliceIssuePage.close(); }
// Grab the bug label id from the filter popup
const filterPage = await adminCtx.newPage();
try {
await filterPage.goto(`${BASE}/ulm-repo/issues`);
const checkbox = filterPage.locator('.label-filter-item')
.filter({ hasText: 'bug' })
.locator('input[name=labels]');
bugLabelId = (await checkbox.getAttribute('value')) ?? '';
} finally { await filterPage.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Settings ──
test('allow_user_labels checkbox is present in repo settings', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').count()).toBe(1);
} finally { await page.close(); }
});
test('allow_user_labels is off by default', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
// ── Label checkboxes hidden when setting is off ──
test('label checkboxes not shown to non-admin on new issue form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes not shown to non-admin on new patch form when allow_user_labels is off', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
test('label checkboxes shown to admin on new issue form regardless of setting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Enable the setting ──
test('admin can enable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.check('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
// Verify it persisted
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(true);
} finally { await page.close(); }
});
// ── Label checkboxes visible when setting is on ──
test('label checkboxes shown to non-admin on new issue form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
const labels = await page.locator('.label-checkbox-list .label-badge').allTextContents();
expect(labels).toContain('bug');
expect(labels).toContain('feature');
} finally { await page.close(); }
});
test('label checkboxes shown to non-admin on new patch form when allow_user_labels is on', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
expect(await page.locator('.label-checkbox-list').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Creating with labels selected ──
test('non-admin can create issue with label selected', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
await page.fill('[name=title]', 'Issue with label');
// Check the 'bug' label checkbox
await page.locator('.label-checkbox-item').filter({ hasText: 'bug' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/issues\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can create patch with label selected', async () => {
writeTempFile('/tmp/ulm-test.patch', VALID_PATCH);
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/patches/new`);
await page.fill('[name=title]', 'Patch with label');
await page.locator('[name=patch_file]').setInputFiles('/tmp/ulm-test.patch');
await page.locator('.label-checkbox-item').filter({ hasText: 'feature' })
.locator('input[type=checkbox]').check();
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/patches\/\d+/);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).toContain('feature');
} finally { await page.close(); }
});
test('label_ids in POST are ignored for non-admin when allow_user_labels is off (no label applied)', async () => {
// Temporarily disable the setting, post with label_ids, re-enable
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main' }, // no allow_user_labels
maxRedirects: 0,
}).catch(() => {});
const resp = await aliceCtx.request.post(`${BASE}/ulm-repo/issues`, {
form: { title: 'Issue sneaking labels', label_ids: bugLabelId },
maxRedirects: 0,
}).catch(() => null);
// Should redirect to the new issue
const location = resp?.headers()['location'] ?? '';
const issueNum = location.split('/issues/')[1];
if (issueNum) {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/${issueNum}`);
const badges = await page.locator('.label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
}
// Re-enable for subsequent tests
await adminCtx.request.post(`${BASE}/ulm-repo/settings`, {
form: { description: '', default_branch: 'main', allow_user_labels: '1' },
maxRedirects: 0,
}).catch(() => {});
});
// ── Add/remove labels on existing items ──
test('non-admin can add label to their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.selectOption('select[name=label_id]', { label: 'bug' });
await page.click('form[action$="/labels/add"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
} finally { await page.close(); }
});
test('non-admin can remove label from their own issue', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const page = await aliceCtx.newPage();
try {
await page.goto(aliceIssueUrl);
await page.click('form[action$="/labels/remove"] button[type=submit]');
await page.waitForURL(new RegExp(`/ulm-repo/issues/${issueNum}`));
const badges = await page.locator('.issue-labels-row .label-badge').allTextContents();
expect(badges).not.toContain('bug');
} finally { await page.close(); }
});
test('non-admin cannot add label to another user\'s issue', async () => {
const issueNum = adminIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('unauthenticated user gets 401 adding a label', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const ctx = await browser.newContext();
try {
const resp = await ctx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(401);
} finally { await ctx.close(); }
});
// ── Disable setting and verify enforcement ──
test('admin can disable allow_user_labels', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/settings`);
await page.uncheck('input[name=allow_user_labels]');
await page.click('form[action$="/settings"] button[type=submit]');
await page.waitForURL(/\/ulm-repo\/settings/);
await page.goto(`${BASE}/ulm-repo/settings`);
expect(await page.locator('input[name=allow_user_labels]').isChecked()).toBe(false);
} finally { await page.close(); }
});
test('non-admin gets 403 adding label to own issue when allow_user_labels is off', async () => {
const issueNum = aliceIssueUrl.split('/issues/')[1];
const resp = await aliceCtx.request.post(
`${BASE}/ulm-repo/issues/${issueNum}/labels/add`,
{ form: { label_id: bugLabelId }, maxRedirects: 0 },
);
expect(resp.status()).toBe(403);
});
test('label checkboxes hidden on new issue form after allow_user_labels disabled', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/ulm-repo/issues/new`);
expect(await page.locator('.label-checkbox-list').count()).toBe(0);
} finally { await page.close(); }
});
});