import { createServerFn } from "@tanstack/react-start"; import { resolveMentions } from "~/server/mentions"; import type { MentionMap } from "~/server/mentions"; import { limitBrowse, limitWrite, MAX_PAGE } 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, requireRepoMutation } from "~/server/repos"; import type { RepoAccess } from "~/server/repos"; import { allocateIssueNumber, indexIssue, loadIssue, setIssueState, touchIssue, applyLabels, issueUrl, } from "~/server/issues"; import type { IssueRow } from "~/server/issues"; import { createIssueComment, notifyIssueThread } from "~/server/issue-comments"; import { addIssueEvent } from "~/server/pulls"; import { afterPullChange } from "~/server/pull-events"; import { accountOrGhost, accountsById } from "~/server/people"; import { renderMarkdown } from "~/server/markdown"; import { reactionSummaries } from "~/server/reactions"; import { templateLabelsFor } from "~/server/issue-templates"; import type { ReactionSummary } from "~/lib/reactions"; import { emitWebhook, recordActivity } from "~/server/events/emit"; import { badRequest, forbidden, notFoundError } from "~/server/errors"; import { markdownBody, repoRef, title } from "~/lib/validation"; import type { JsonValue, RepoRow } from "~/lib/types"; const PAGE_SIZE = 25; export type LabelInfo = { id: string; name: string; color: string; description: string | null }; function canMaintain(access: RepoAccess): boolean { return access.permission === "write" || access.permission === "admin"; } function md(repo: RepoRow, body: string, mentions: MentionMap): string { return renderMarkdown( body, { owner: repo.owner_name, repo: repo.name, ref: repo.default_branch, basePath: "" }, mentions, ); } async function labelsFor(issueIds: string[]): Promise> { const out = new Map(); if (issueIds.length === 0) return out; const rows = await all( `SELECT il.issue_id, l.id, l.name, l.color, l.description FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id IN (${placeholders(issueIds.length)}) ORDER BY l.name`, ...issueIds, ); for (const row of rows) { out.set(row.issue_id, [ ...(out.get(row.issue_id) ?? []), { id: row.id, name: row.name, color: row.color, description: row.description }, ]); } return out; } const listInput = repoRef.extend({ kind: z.enum(["issue", "pull"]), state: z.enum(["open", "closed", "all"]).default("open"), q: z.string().max(200).optional(), label: z.string().optional(), author: z.string().optional(), page: z.number().int().min(1).max(MAX_PAGE).default(1), }); export const listIssues = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(listInput) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const conditions = ["i.repo_id = ?", "i.is_pull = ?"]; const params: Array = [repo.id, data.kind === "pull" ? 1 : 0]; if (data.state !== "all") { conditions.push("i.state = ?"); params.push(data.state); } if (data.label) { conditions.push( "EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)", ); params.push(data.label); } if (data.author) { conditions.push("i.author_id = (SELECT id FROM accounts WHERE name = ?)"); params.push(data.author); } const query = data.q?.trim(); if (query) { conditions.push("i.id IN (SELECT issue_id FROM issues_fts WHERE issues_fts MATCH ? AND repo_id = ?)"); params.push( query .replace(/["*]/g, " ") .split(/\s+/) .filter(Boolean) .map((term) => `"${term}"*`) .join(" "), repo.id, ); } const where = conditions.join(" AND "); const rows = await all( `SELECT i.* FROM issues i WHERE ${where} ORDER BY i.number DESC LIMIT ? OFFSET ?`, ...params, PAGE_SIZE + 1, (data.page - 1) * PAGE_SIZE, ); const counts = await first<{ open: number; closed: number }>( `SELECT SUM(state = 'open') AS open, SUM(state = 'closed') AS closed FROM issues WHERE repo_id = ? AND is_pull = ?`, repo.id, data.kind === "pull" ? 1 : 0, ); const page = rows.slice(0, PAGE_SIZE); const [people, labels] = await Promise.all([ accountsById(page.map((row) => row.author_id)), labelsFor(page.map((row) => row.id)), ]); const pullMeta = data.kind === "pull" && page.length > 0 ? await all<{ issue_id: string; draft: number; merged_at: number | null; head_ref: string; base_ref: string; head_sha: string; }>( `SELECT issue_id, draft, merged_at, head_ref, base_ref, head_sha FROM pull_requests WHERE issue_id IN (${placeholders(page.length)})`, ...page.map((row) => row.id), ) : []; return { openCount: counts?.open ?? 0, closedCount: counts?.closed ?? 0, hasMore: rows.length > PAGE_SIZE, items: page.map((row) => { const pull = pullMeta.find((meta) => meta.issue_id === row.id); return { number: row.number, title: row.title, state: row.state, author: accountOrGhost(people, row.author_id), commentsCount: row.comments_count, createdAt: row.created_at, updatedAt: row.updated_at, closedAt: row.closed_at, labels: labels.get(row.id) ?? [], draft: pull ? pull.draft === 1 : false, merged: pull ? pull.merged_at !== null : false, }; }), }; }); export const getIssue = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int().positive() })) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const access = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const { repo } = access; const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); const [comments, events, assignees, labels, reviews] = await Promise.all([ all<{ id: string; author_id: string; body: string; created_at: number; updated_at: number }>( "SELECT * FROM (SELECT id, author_id, body, created_at, updated_at FROM comments WHERE issue_id = ? ORDER BY created_at DESC LIMIT 300) ORDER BY created_at", issue.id, ), all<{ id: string; actor_id: string | null; kind: string; data: string; created_at: number }>( "SELECT * FROM (SELECT id, actor_id, kind, data, created_at FROM issue_events WHERE issue_id = ? ORDER BY created_at DESC LIMIT 300) ORDER BY created_at", issue.id, ), all<{ user_id: string }>("SELECT user_id FROM issue_assignees WHERE issue_id = ?", issue.id), labelsFor([issue.id]), all<{ id: string; author_id: string; state: string; body: string; commit_sha: string; created_at: number }>( "SELECT id, author_id, state, body, commit_sha, created_at FROM reviews WHERE issue_id = ? ORDER BY created_at", issue.id, ), ]); const people = await accountsById([ issue.author_id, issue.closed_by, ...comments.map((comment) => comment.author_id), ...events.map((event) => event.actor_id), ...assignees.map((assignee) => assignee.user_id), ...reviews.map((review) => review.author_id), ]); const viewer = context.viewer; const maintainer = canMaintain(access); const [issueReactions, commentReactions, mentions] = await Promise.all([ reactionSummaries("issue", [issue.id], viewer?.id ?? null), reactionSummaries( "comment", comments.map((comment) => comment.id), viewer?.id ?? null, ), resolveMentions([ issue.body, ...comments.map((comment) => comment.body), ...reviews.map((review) => review.body), ]), ]); type TimelineItem = | { type: "comment"; id: string; author: ReturnType; html: string; body: string; createdAt: number; edited: boolean; canEdit: boolean; reactions: ReactionSummary[]; } | { type: "event"; id: string; actor: ReturnType; kind: string; data: { [key: string]: JsonValue }; createdAt: number; } | { type: "review"; id: string; author: ReturnType; state: string; html: string; commitSha: string; createdAt: number; }; const timeline: TimelineItem[] = [ ...comments.map((comment): TimelineItem => ({ type: "comment", id: comment.id, author: accountOrGhost(people, comment.author_id), html: md(repo, comment.body, mentions), body: comment.body, createdAt: comment.created_at, edited: comment.updated_at > comment.created_at, canEdit: Boolean(viewer && (viewer.id === comment.author_id || maintainer)), reactions: commentReactions.get(comment.id) ?? [], })), ...events.map((event): TimelineItem => ({ type: "event", id: event.id, actor: accountOrGhost(people, event.actor_id), kind: event.kind, data: JSON.parse(event.data), createdAt: event.created_at, })), ...reviews .filter((review) => review.state !== "commented" || review.body.trim() !== "") .map((review): TimelineItem => ({ type: "review", id: review.id, author: accountOrGhost(people, review.author_id), state: review.state, html: md(repo, review.body, mentions), commitSha: review.commit_sha, createdAt: review.created_at, })), ].sort((left, right) => left.createdAt - right.createdAt); return { issue: { id: issue.id, number: issue.number, title: issue.title, body: issue.body, html: md(repo, issue.body, mentions), state: issue.state, isPull: issue.is_pull === 1, locked: issue.locked === 1, author: accountOrGhost(people, issue.author_id), createdAt: issue.created_at, closedAt: issue.closed_at, closedBy: issue.closed_by ? accountOrGhost(people, issue.closed_by) : null, reactions: issueReactions.get(issue.id) ?? [], }, timeline, assignees: assignees.map((assignee) => accountOrGhost(people, assignee.user_id)), labels: labels.get(issue.id) ?? [], canMaintain: maintainer, canEditIssue: Boolean(viewer && (viewer.id === issue.author_id || maintainer)), canComment: Boolean(viewer) && (issue.locked === 0 || maintainer), }; }); export const createIssue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ title, body: markdownBody, labels: z.array(z.string()).max(50).default([]), template: z.string().max(200).optional(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "read"); const { repo } = access; if (repo.has_issues !== 1) throw forbidden("Issues are disabled for this repository"); const number = await allocateIssueNumber(repo.id); const id = randomId(); const now = Date.now(); await batch([ stmt( `INSERT INTO issues (id, repo_id, number, author_id, title, body, state, is_pull, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'open', 0, ?, ?)`, id, repo.id, number, viewer.id, data.title, data.body, now, now, ), stmt("UPDATE repos SET open_issues_count = open_issues_count + 1 WHERE id = ?", repo.id), ]); const templateLabels = data.template ? await templateLabelsFor(repo, data.template) : []; const labels = canMaintain(access) ? [...data.labels, ...templateLabels] : templateLabels; if (labels.length > 0) await applyLabels(repo.id, id, [...new Set(labels)]); await indexIssue({ id, repo_id: repo.id, title: data.title, body: data.body }); const issue = await loadIssue(repo.id, number); if (issue) { await recordActivity(viewer.id, repo.id, "issue_opened", { number, title: data.title }); await emitWebhook(repo.id, "issues", { action: "opened", issue: { number, title: data.title, body: data.body, html_url: issueUrl(repo, issue) }, sender: { login: viewer.name, id: viewer.id }, }); await notifyIssueThread(repo, issue, viewer, "comment", data.body); } return { number }; }); export const updateIssue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int(), title: title.optional(), body: markdownBody.optional() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "read"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (issue.author_id !== viewer.id && !canMaintain(access)) throw forbidden(); const nextTitle = data.title ?? issue.title; const nextBody = data.body ?? issue.body; await run( "UPDATE issues SET title = ?, body = ?, updated_at = ? WHERE id = ?", nextTitle, nextBody, Date.now(), issue.id, ); if (data.title && data.title !== issue.title) await addIssueEvent(issue.id, viewer.id, "renamed", { from: issue.title, to: data.title }); await indexIssue({ id: issue.id, repo_id: issue.repo_id, title: nextTitle, body: nextBody }); return { ok: true }; }); export const setIssueOpenState = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int(), state: z.enum(["open", "closed"]) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "read"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (issue.author_id !== viewer.id && !canMaintain(access)) throw forbidden(); if (issue.is_pull === 1) { const merged = await first("SELECT 1 FROM pull_requests WHERE issue_id = ? AND merged_at IS NOT NULL", issue.id); if (merged) throw badRequest("Merged pull requests cannot be reopened"); } await setIssueState(issue, data.state, viewer.id); if (issue.is_pull === 1 && data.state === "open") afterPullChange(issue.id, "reopened", viewer); const event = issue.is_pull === 1 ? "pull_request" : "issues"; await emitWebhook(access.repo.id, event, { action: data.state === "closed" ? "closed" : "reopened", [issue.is_pull === 1 ? "pull_request" : "issue"]: { number: issue.number, title: issue.title, html_url: issueUrl(access.repo, issue), }, sender: { login: viewer.name, id: viewer.id }, }); await notifyIssueThread(access.repo, issue, viewer, "state_change", null); return { ok: true }; }); export const addComment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ number: z.number().int(), body: markdownBody.min(1), closeAfter: z.boolean().default(false) }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "read"); const issue = await loadIssue(access.repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); await createIssueComment(access, issue, viewer, data.body, { closeAfter: data.closeAfter }); return { ok: true }; }); export const editComment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ commentId: z.string(), body: markdownBody.min(1).nullable() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requireRepoMutation(data.owner, data.repo, viewer, "read"); const comment = await first<{ id: string; author_id: string; issue_id: string }>( "SELECT c.id, c.author_id, c.issue_id FROM comments c JOIN issues i ON i.id = c.issue_id WHERE c.id = ? AND i.repo_id = ?", data.commentId, access.repo.id, ); if (!comment) throw notFoundError("Comment not found"); if (comment.author_id !== viewer.id && !canMaintain(access)) throw forbidden(); if (data.body === null) { await batch([ stmt("DELETE FROM reactions WHERE subject_type = 'comment' AND subject_id = ?", comment.id), stmt("DELETE FROM comments WHERE id = ?", comment.id), stmt("UPDATE issues SET comments_count = MAX(comments_count - 1, 0) WHERE id = ?", comment.issue_id), ]); } else { await run("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?", data.body, Date.now(), comment.id); } await touchIssue(comment.issue_id); return { ok: true }; });