import { createServerFn } from "@tanstack/react-start"; import { limitWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, placeholders } from "~/server/db"; import { repoDb, stmt } from "~/server/scoped-db"; import { deleteLabel as deleteRepoLabel, listRepoLabels, saveLabel as saveRepoLabel } from "~/server/label-write"; import { requireRepoAccess, requireRepoMutation } from "~/server/repos"; import { assertAssignable } from "~/server/assignable"; import { canListOrgMembers } from "~/server/accounts"; 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"; import { issuesChanged } from "~/server/repo-state"; 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); await limitWrite(viewer); const access = await requireRepoMutation(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, 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); await limitWrite(viewer); const access = await requireRepoMutation(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; name: string }>( `SELECT a.id, a.name FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name IN (${placeholders(data.usernames.length)})`, ...data.usernames, ) : []; await assertAssignable(access.repo, users); await repoDb(access.repo.id).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, viewer.id, "assigned", { usernames: data.usernames }); issuesChanged(access.repo.id, [issue.id]); 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); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); await repoDb(access.repo.id).run("UPDATE issues SET locked = ? WHERE id = ?", data.locked ? 1 : 0, issue.id); await addIssueEvent(issue, 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"); const labels: Array = await listRepoLabels(repo.id); return labels; }); 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 }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoMutation(data.owner, data.repo, context.viewer, "write"); await saveRepoLabel(repo.id, data.id, data); return { ok: true }; }); export const deleteLabel = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoMutation(data.owner, data.repo, context.viewer, "write"); await deleteRepoLabel(repo.id, data.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"); const showMembers = await canListOrgMembers(repo.owner_id, context.viewer); 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 JOIN accounts o ON o.id = m.org_id WHERE ?3 = 1 AND m.org_id = ?1 AND (m.role = 'owner' OR o.org_default_permission != 'none') ORDER BY 1`, repo.owner_id, repo.id, showMembers ? 1 : 0, ); });