import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all } from "~/server/db"; import { repoDb } from "~/server/scoped-db"; import { limitWrite } from "~/server/limits"; import { loadRepoById, requireRepoAccess, requireRepoMutation, hasPermission, repoPermission, repoWritableSql, } from "~/server/repos"; import { loadIssue } from "~/server/issues"; import { addIssueEvent } from "~/server/pulls"; import { assertCount, PLANNING_CAPS, resolveIssueNumber, subIssueProgress, transferIssue } from "~/server/planning"; import { badRequest, forbidden, notFoundError } from "~/server/errors"; import { repoRef } from "~/lib/validation"; const issueRef = repoRef.extend({ number: z.number().int().positive() }); const MAX_TRANSFER_TARGETS = 50; const MAX_ANCESTRY_WALK = 10_000; export const getIssuePlanning = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(issueRef) .handler(async ({ data, context }) => { const { repo, permission } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); const [milestone, type, parent, children, pinned, milestones, types] = await Promise.all([ issue.milestone_id ? repoDb(repo.id).first<{ id: string; title: string }>( "SELECT id, title FROM milestones WHERE id = ?", issue.milestone_id, ) : null, issue.type_id ? repoDb(repo.id).first<{ id: string; name: string; color: string }>( "SELECT id, name, color FROM issue_types WHERE id = ?", issue.type_id, ) : null, issue.parent_id ? repoDb(repo.id).first<{ number: number; title: string; state: string; }>("SELECT number, title, state FROM issues WHERE id = ?", issue.parent_id) : null, repoDb(repo.id).all<{ id: string; number: number; title: string; state: "open" | "closed"; }>( "SELECT id, number, title, state FROM issues WHERE parent_id = ? AND hidden_at IS NULL ORDER BY number LIMIT ?", issue.id, PLANNING_CAPS.subIssuesPerParent, ), repoDb(repo.id).first("SELECT 1 FROM pinned_issues WHERE issue_id = ?", issue.id), repoDb(repo.id).all<{ id: string; title: string }>( "SELECT id, title FROM milestones WHERE repo_id = ? AND state = 'open' ORDER BY title", repo.id, ), repoDb(repo.id).all<{ id: string; name: string; color: string }>( "SELECT id, name, color FROM issue_types WHERE repo_id = ? ORDER BY name", repo.id, ), ]); const progress = await subIssueProgress( children.map((child) => child.id), repo.id, ); return { milestone, type, parent, subIssues: children.map((child) => ({ number: child.number, title: child.title, state: child.state, progress: progress.get(child.id) ?? null, })), pinned: pinned !== null, milestones, types, canMaintain: hasPermission(permission, "write"), }; }); export const setIssuePlanning = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( issueRef.extend({ milestoneId: z.string().max(64).nullable().optional(), typeId: z.string().max(64).nullable().optional(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (data.milestoneId !== undefined) { const milestone = data.milestoneId ? await repoDb(repo.id).first<{ id: string; title: string }>( "SELECT id, title FROM milestones WHERE id = ? AND repo_id = ?", data.milestoneId, repo.id, ) : null; if (data.milestoneId && !milestone) throw notFoundError("Milestone not found"); await repoDb(repo.id).run("UPDATE issues SET milestone_id = ? WHERE id = ?", milestone?.id ?? null, issue.id); await addIssueEvent(issue, viewer.id, milestone ? "milestoned" : "demilestoned", { milestone: milestone?.title ?? "", }); } if (data.typeId !== undefined) { const type = data.typeId ? await repoDb(repo.id).first<{ id: string; name: string }>( "SELECT id, name FROM issue_types WHERE id = ? AND repo_id = ?", data.typeId, repo.id, ) : null; if (data.typeId && !type) throw notFoundError("Issue type not found"); await repoDb(repo.id).run("UPDATE issues SET type_id = ? WHERE id = ?", type?.id ?? null, issue.id); await addIssueEvent(issue, viewer.id, "typed", { type: type?.name ?? "" }); } return { ok: true }; }); export const setSubIssue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(issueRef.extend({ child: z.number().int().positive(), attach: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const [parent, child] = await Promise.all([loadIssue(repo.id, data.number), loadIssue(repo.id, data.child)]); if (!parent || !child) throw notFoundError("Issue not found"); if (child.is_pull === 1) throw badRequest("Pull requests cannot be sub-issues"); if (!data.attach) { if (child.parent_id !== parent.id) return { ok: true }; await repoDb(repo.id).run("UPDATE issues SET parent_id = NULL WHERE id = ?", child.id); await addIssueEvent(parent, viewer.id, "sub_issue_removed", { number: child.number }); return { ok: true }; } if (child.id === parent.id) throw badRequest("An issue cannot be its own sub-issue"); const cycle = await repoDb(repo.id).first( `WITH RECURSIVE ancestors(id, depth) AS ( SELECT parent_id, 1 FROM issues WHERE id = ?1 UNION ALL SELECT i.parent_id, a.depth + 1 FROM issues i JOIN ancestors a ON i.id = a.id WHERE a.depth < ?3 ) SELECT 1 FROM ancestors WHERE id = ?2`, parent.id, child.id, MAX_ANCESTRY_WALK, ); if (cycle) throw badRequest("That would create a cycle of sub-issues"); await assertCount( "SELECT COUNT(*) AS count FROM issues WHERE parent_id = ?", parent.id, PLANNING_CAPS.subIssuesPerParent, "sub-issues per issue", repoDb(repo.id), ); await repoDb(repo.id).run("UPDATE issues SET parent_id = ? WHERE id = ?", parent.id, child.id); await addIssueEvent(parent, viewer.id, "sub_issue_added", { number: child.number }); return { ok: true }; }); export const setIssuePinned = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(issueRef.extend({ pinned: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (!data.pinned) { await repoDb(repo.id).run("DELETE FROM pinned_issues WHERE issue_id = ?", issue.id); return { ok: true }; } if (await repoDb(repo.id).first("SELECT 1 FROM pinned_issues WHERE issue_id = ?", issue.id)) return { ok: true }; await limitWrite(viewer); await assertCount( "SELECT COUNT(*) AS count FROM pinned_issues WHERE repo_id = ?", repo.id, PLANNING_CAPS.pinnedPerRepo, "pinned issues", repoDb(repo.id), ); await repoDb(repo.id).run( "INSERT OR IGNORE INTO pinned_issues (repo_id, issue_id, created_at) VALUES (?, ?, ?)", repo.id, issue.id, Date.now(), ); await addIssueEvent(issue, viewer.id, "pinned", {}); return { ok: true }; }); export const listPinnedIssues = 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<{ number: number; title: string; state: "open" | "closed"; comments_count: number; }>( `SELECT i.number, i.title, i.state, i.comments_count FROM pinned_issues p JOIN issues i ON i.id = p.issue_id WHERE p.repo_id = ? AND i.hidden_at IS NULL ORDER BY p.created_at LIMIT ?`, repo.id, PLANNING_CAPS.pinnedPerRepo, ); }); /** Follows a transfer redirect so stale `/issues/` links land on the issue's new home. */ export const findIssueRedirect = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(issueRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const issue = await resolveIssueNumber(repo.id, data.number); if (!issue || issue.repo_id === repo.id) return null; const target = await loadRepoById(issue.repo_id); if (!target || (await repoPermission(target, context.viewer)) === "none") return null; return { owner: target.owner_name, repo: target.name, number: issue.number }; }); export const listTransferTargets = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const rows = await all<{ owner: string; name: string; id: string }>( `SELECT a.name AS owner, r.name, r.id FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE (?1 = 1 OR ${repoWritableSql("r", "?2")}) AND r.has_issues = 1 AND r.is_archived = 0 AND r.id != ?3 AND (?4 = 'public' OR r.visibility = 'private') ORDER BY r.pushed_at DESC LIMIT ?5`, viewer.isAdmin ? 1 : 0, viewer.id, repo.id, repo.visibility, MAX_TRANSFER_TARGETS, ); return rows.map((row) => `${row.owner}/${row.name}`); }); export const transferIssueTo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(issueRef.extend({ target: z.string().regex(/^[^/]+\/[^/]+$/) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoMutation(data.owner, data.repo, viewer, "write"); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (issue.is_pull === 1) throw badRequest("Pull requests cannot be transferred"); const [targetOwner, targetName] = data.target.split("/"); const target = await requireRepoMutation(targetOwner, targetName, viewer, "write"); if (target.repo.id === repo.id) throw badRequest("Pick a different repository"); if (target.repo.has_issues !== 1) throw forbidden("Issues are disabled in the target repository"); if (repo.is_archived === 1 || target.repo.is_archived === 1) throw forbidden("Archived repositories are read-only"); if (repo.visibility === "private" && target.repo.visibility !== "private") throw forbidden("Issues cannot be transferred from a private repository to a public one"); const number = await transferIssue(issue, repo, target.repo, viewer.id); return { owner: target.repo.owner_name, repo: target.repo.name, number }; });