import { createServerFn } from "@tanstack/react-start"; import { resolveMentions } from "~/server/mentions"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { repoDb } from "~/server/scoped-db"; import { randomId } from "~/server/crypto"; import { limitWrite } from "~/server/limits"; import { requireRepoAccess, requireRepoMutation } from "~/server/repos"; import { assertCount, PLANNING_CAPS } from "~/server/planning"; import { renderMarkdown } from "~/server/markdown"; import { accountOrGhost, accountsById } from "~/server/people"; import { notFoundError } from "~/server/errors"; import { labelColor, markdownBody, repoRef, title } from "~/lib/validation"; type MilestoneRow = { id: string; title: string; description: string; due_on: number | null; state: "open" | "closed"; updated_at: number; open_count: number; closed_count: number; }; const MILESTONE_SELECT = `SELECT m.id, m.title, m.description, m.due_on, m.state, m.updated_at, (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'open') AS open_count, (SELECT COUNT(*) FROM issues i WHERE i.milestone_id = m.id AND i.state = 'closed') AS closed_count FROM milestones m`; const MILESTONE_ISSUES_LIMIT = 200; function toMilestone(row: MilestoneRow) { return { id: row.id, title: row.title, description: row.description, dueOn: row.due_on, state: row.state, updatedAt: row.updated_at, openCount: row.open_count, closedCount: row.closed_count, }; } export const listMilestones = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ state: z.enum(["open", "closed", "all"]).default("open") })) .handler(async ({ data, context }) => { const access = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const rows = await repoDb(access.repo.id).all( `${MILESTONE_SELECT} WHERE m.repo_id = ?1 AND (?2 = 'all' OR m.state = ?2) ORDER BY m.state DESC, m.due_on IS NULL, m.due_on, m.title`, access.repo.id, data.state, ); return { milestones: rows.map(toMilestone), canMaintain: access.permission === "write" || access.permission === "admin", }; }); export const getMilestone = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().min(1).max(64) })) .handler(async ({ data, context }) => { const access = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const row = await repoDb(access.repo.id).first( `${MILESTONE_SELECT} WHERE m.repo_id = ? AND m.id = ?`, access.repo.id, data.id, ); if (!row) throw notFoundError("Milestone not found"); const issues = await repoDb(access.repo.id).all<{ number: number; title: string; state: "open" | "closed"; is_pull: number; author_id: string; updated_at: number; }>( "SELECT number, title, state, is_pull, author_id, updated_at FROM issues WHERE milestone_id = ? AND hidden_at IS NULL ORDER BY state DESC, number DESC LIMIT ?", row.id, MILESTONE_ISSUES_LIMIT, ); const [people, mentions] = await Promise.all([ accountsById(issues.map((issue) => issue.author_id)), resolveMentions([row.description]), ]); return { milestone: { ...toMilestone(row), html: renderMarkdown( row.description, { owner: access.repo.owner_name, repo: access.repo.name, ref: access.repo.default_branch, basePath: "" }, mentions, ), }, issues: issues.map((issue) => ({ number: issue.number, title: issue.title, state: issue.state, isPull: issue.is_pull === 1, author: accountOrGhost(people, issue.author_id).name, updatedAt: issue.updated_at, })), canMaintain: access.permission === "write" || access.permission === "admin", }; }); export const saveMilestone = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().max(64).nullable(), title, description: markdownBody.default(""), dueOn: z.number().int().positive().nullable(), state: z.enum(["open", "closed"]).default("open"), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const now = Date.now(); if (data.id) { await repoDb(repo.id).run( "UPDATE milestones SET title = ?, description = ?, due_on = ?, state = ?, updated_at = ? WHERE id = ? AND repo_id = ?", data.title, data.description, data.dueOn, data.state, now, data.id, repo.id, ); return { id: data.id }; } await assertCount( "SELECT COUNT(*) AS count FROM milestones WHERE repo_id = ?", repo.id, PLANNING_CAPS.milestonesPerRepo, "milestones", repoDb(repo.id), ); const id = randomId(); await repoDb(repo.id).run( "INSERT INTO milestones (id, repo_id, title, description, due_on, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'open', ?, ?)", id, repo.id, data.title, data.description, data.dueOn, now, now, ); return { id }; }); export const deleteMilestone = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().max(64) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); await repoDb(repo.id).run("DELETE FROM milestones WHERE id = ? AND repo_id = ?", data.id, repo.id); return { ok: true }; }); export const listIssueTypes = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); return repoDb(repo.id).all<{ id: string; name: string; color: string; description: string | null; }>("SELECT id, name, color, description FROM issue_types WHERE repo_id = ? ORDER BY name", repo.id); }); export const saveIssueType = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().max(64).nullable(), name: z.string().trim().min(1).max(40), color: labelColor, description: z.string().max(100).nullable(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); if (data.id) { await repoDb(repo.id).run( "UPDATE issue_types SET name = ?, color = ?, description = ? WHERE id = ? AND repo_id = ?", data.name, data.color, data.description, data.id, repo.id, ); return { ok: true }; } await assertCount( "SELECT COUNT(*) AS count FROM issue_types WHERE repo_id = ?", repo.id, PLANNING_CAPS.issueTypesPerRepo, "issue types", repoDb(repo.id), ); await repoDb(repo.id).run( "INSERT INTO issue_types (id, repo_id, name, color, description) VALUES (?, ?, ?, ?, ?)", randomId(), repo.id, data.name, data.color, data.description, ); return { ok: true }; }); export const deleteIssueType = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().max(64) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); await repoDb(repo.id).run("DELETE FROM issue_types WHERE id = ? AND repo_id = ?", data.id, repo.id); return { ok: true }; });