import { createServerFn } from "@tanstack/react-start"; import { assertBelowCap, CAPS } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, batch, first, placeholders, run, stmt } from "~/server/db"; import { randomId } from "~/server/crypto"; import { requireRepoAccess } from "~/server/repos"; import { loadIssue, applyLabels, issueUrl } from "~/server/issues"; import { addIssueEvent } from "~/server/pulls"; import { emitNotification } from "~/server/events/emit"; import { notFoundError } from "~/server/errors"; import { labelColor, repoRef } from "~/lib/validation"; import type { LabelInfo } from "./issues"; export const setIssueLabels = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int(), labels: z.array(z.string()).max(50) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const access = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); await applyLabels(access.repo.id, issue.id, data.labels); await addIssueEvent(issue.id, viewer.id, "labeled", { labels: data.labels }); return { ok: true }; }); export const setIssueAssignees = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int(), usernames: z.array(z.string()).max(10) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const access = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); const users = data.usernames.length ? await all<{ id: string }>( `SELECT a.id FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name IN (${placeholders(data.usernames.length)})`, ...data.usernames, ) : []; await batch([ stmt("DELETE FROM issue_assignees WHERE issue_id = ?", issue.id), ...users.map((user) => stmt("INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?)", issue.id, user.id)), ]); await addIssueEvent(issue.id, viewer.id, "assigned", { usernames: data.usernames }); if (users.length > 0) { await emitNotification({ repoId: access.repo.id, issueId: null, actorId: viewer.id, reason: "assigned", title: `You were assigned ${access.repo.owner_name}/${access.repo.name}#${issue.number}`, url: issueUrl(access.repo, issue), mentionText: null, directRecipients: users.map((user) => user.id), }); } return { ok: true }; }); export const setIssueLock = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int(), locked: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const access = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); await run("UPDATE issues SET locked = ? WHERE id = ?", data.locked ? 1 : 0, issue.id); await addIssueEvent(issue.id, viewer.id, data.locked ? "locked" : "unlocked", {}); return { ok: true }; }); export const listLabels = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); return all( `SELECT l.id, l.name, l.color, l.description, (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id WHERE il.label_id = l.id AND i.state = 'open') AS open_count FROM labels l WHERE l.repo_id = ? ORDER BY l.name`, repo.id, ); }); export const saveLabel = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().nullable(), name: z.string().trim().min(1).max(50), color: labelColor, description: z.string().max(100).nullable(), }), ) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "write"); if (!data.id) assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM labels WHERE repo_id = ?", repo.id))?.count ?? 0, CAPS.labelsPerRepo, "labels", ); if (data.id) { await run( "UPDATE labels SET name = ?, color = ?, description = ? WHERE id = ? AND repo_id = ?", data.name, data.color, data.description, data.id, repo.id, ); } else { await run( "INSERT INTO labels (id, repo_id, name, color, description) VALUES (?, ?, ?, ?, ?)", randomId(), repo.id, data.name, data.color, data.description, ); } return { ok: true }; }); export const deleteLabel = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "write"); await run("DELETE FROM labels WHERE id = ? AND repo_id = ?", data.id, repo.id); return { ok: true }; }); export const listAssignable = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); return all<{ name: string }>( `SELECT a.name FROM accounts a WHERE a.id = ?1 AND a.kind = 'user' UNION SELECT a.name FROM collaborators c JOIN accounts a ON a.id = c.user_id WHERE c.repo_id = ?2 UNION SELECT a.name FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ?1 ORDER BY 1`, repo.owner_id, repo.id, ); });