import { pullInput, pullUrl, requirePull } from "~/server/pull-access"; import { limitHeavy, limitWrite } from "~/server/limits"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, batch, first, run, stmt } from "~/server/db"; import { randomId } from "~/server/crypto"; import { loadRepo, repoPermission, requireRepoAccess } from "~/server/repos"; import { branchSha, listRefs } from "~/server/git/refs"; import { withStore } from "~/server/git/store"; import { deleteRef } from "~/server/git/writer"; import { compareCommits } from "~/server/git/history"; import { allocateIssueNumber, indexIssue } from "~/server/issues"; import { addIssueEvent, checkMergeability, MergeError, mergeGate, performMerge } from "~/server/pulls"; import { afterPullChange } from "~/server/pull-events"; import { pullDiff } from "~/server/pull-files"; import { parseSuggestion } from "~/server/suggestions"; import { pullAutomation } from "~/server/merge-queue"; import { listLinkedIssues } from "~/server/pull-links"; import { listCommitStatuses, combinedState } from "~/server/statuses"; import { accountOrGhost, accountsById } from "~/server/people"; import { renderMarkdown } from "~/server/markdown"; import { reactionSummaries } from "~/server/reactions"; import { emitNotification, emitWebhook, recordActivity } from "~/server/events/emit"; import { badRequest, conflict, forbidden, notFoundError } from "~/server/errors"; import { markdownBody, repoRef, title } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; export const createPull = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ base: z.string().min(1), head: z.string().min(1), title, body: markdownBody, draft: z.boolean().default(false), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); const separator = data.head.indexOf(":"); const headRepo = separator === -1 ? repo : await loadRepo(data.head.slice(0, separator), ""); let resolvedHeadRepo: RepoRow | null = headRepo; const headRef = separator === -1 ? data.head : data.head.slice(separator + 1); if (separator !== -1) { resolvedHeadRepo = await first( `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ? AND (r.fork_of_id = ? OR r.id = ? OR r.id = ?)`, data.head.slice(0, separator), repo.id, repo.id, repo.fork_of_id ?? "", ); } if (!resolvedHeadRepo || (await repoPermission(resolvedHeadRepo, viewer)) === "none") throw notFoundError("Head repository not found"); const [baseRefs, headRefs] = await Promise.all([listRefs(repo), listRefs(resolvedHeadRepo)]); const baseSha = branchSha(baseRefs, data.base); const headSha = branchSha(headRefs, headRef); if (!baseSha || !headSha) throw badRequest("Both branches must exist"); if (resolvedHeadRepo.id === repo.id && headRef === data.base) throw badRequest("Head and base must differ"); const duplicate = await first( `SELECT 1 FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.repo_id = ? AND p.head_repo_id = ? AND p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'`, repo.id, resolvedHeadRepo.id, headRef, data.base, ); if (duplicate) throw conflict("A pull request already exists for these branches"); const mergeBase = await withStore(repo.artifacts_name, (baseStore) => withStore( resolvedHeadRepo.artifacts_name, async (headStore) => (await compareCommits(baseStore, baseSha, headStore, headSha)).mergeBase, ), ); 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', 1, ?, ?)`, id, repo.id, number, viewer.id, data.title, data.body, now, now, ), stmt( `INSERT INTO pull_requests (issue_id, repo_id, head_repo_id, head_ref, head_sha, base_ref, base_sha, merge_base_sha, draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.id, resolvedHeadRepo.id, headRef, headSha, data.base, baseSha, mergeBase, data.draft ? 1 : 0, ), stmt("UPDATE repos SET open_pulls_count = open_pulls_count + 1 WHERE id = ?", repo.id), ]); await indexIssue({ id, repo_id: repo.id, title: data.title, body: data.body }); await recordActivity(viewer.id, repo.id, "pull_opened", { number, title: data.title }); await emitNotification({ repoId: repo.id, issueId: id, actorId: viewer.id, reason: "comment", title: `${repo.owner_name}/${repo.name}#${number}: ${data.title}`, url: pullUrl(repo, number), mentionText: data.body, directRecipients: [], }); afterPullChange(id, "opened", viewer); return { number }; }); export const getPull = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(pullInput) .handler(async ({ data, context }) => { const { repo, permission, pull, headRepo } = await requirePull( data.owner, data.repo, data.number, context.viewer, "read", ); const [statuses, gate, reviewRows, requested, automation, linkedIssues] = await Promise.all([ listCommitStatuses(repo.id, pull.head_sha), mergeGate(pull, permission), all<{ id: string; author_id: string; state: string; commit_sha: string; dismiss_reason: string | null }>( `SELECT id, author_id, state, commit_sha, dismiss_reason FROM reviews WHERE issue_id = ? AND state != 'commented' ORDER BY created_at`, pull.issue_id, ), all<{ user_id: string; code_owner: number }>( "SELECT user_id, code_owner FROM review_requests WHERE issue_id = ?", pull.issue_id, ), pullAutomation(pull), listLinkedIssues(pull), ]); const latestReviews = new Map(); for (const review of reviewRows) latestReviews.set(review.author_id, review); const people = await accountsById([ pull.merged_by, automation.autoMerge?.enabledBy ?? null, ...latestReviews.keys(), ...requested.map((row) => row.user_id), ]); const comparison = await withStore(repo.artifacts_name, (baseStore) => withStore(headRepo.artifacts_name, (headStore) => compareCommits(baseStore, pull.base_sha, headStore, pull.head_sha), ), ); const headBranchExists = branchSha(await listRefs(headRepo), pull.head_ref) !== null; return { pull: { number: pull.number, headRef: pull.head_ref, headSha: pull.head_sha, headLabel: headRepo.id === repo.id ? pull.head_ref : `${headRepo.owner_name}:${pull.head_ref}`, headRepo: { owner: headRepo.owner_name, name: headRepo.name }, baseRef: pull.base_ref, draft: pull.draft === 1, mergeable: pull.mergeable, merged: pull.merged_at !== null, mergedAt: pull.merged_at, mergedBy: pull.merged_by ? accountOrGhost(people, pull.merged_by) : null, mergeCommitSha: pull.merge_commit_sha, commitsCount: comparison.commits.length, behindBy: comparison.behindBy, headBranchExists, }, checks: statuses.map((status) => ({ context: status.context, state: status.state, description: status.description, targetUrl: status.target_url, })), checksState: combinedState(statuses), gate, reviews: [...latestReviews.entries()].map(([userId, review]) => ({ id: review.id, reviewer: accountOrGhost(people, userId), state: review.state, stale: review.commit_sha !== pull.head_sha, dismissReason: review.dismiss_reason, })), requestedReviewers: requested.map((row) => ({ ...accountOrGhost(people, row.user_id), codeOwner: row.code_owner === 1, })), autoMerge: automation.autoMerge && { method: automation.autoMerge.method, enabledBy: accountOrGhost(people, automation.autoMerge.enabledBy), }, queue: automation.queue, linkedIssues, canUpdateBranch: comparison.behindBy > 0 && pull.state === "open", canMerge: permission === "write" || permission === "admin", canPushHead: (await repoPermission(headRepo, context.viewer)) !== "read", }; }); export const getPullCommits = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(pullInput) .handler(async ({ data, context }) => { const { repo, pull, headRepo } = await requirePull(data.owner, data.repo, data.number, context.viewer, "read"); const base = pull.merged_at && pull.merge_base_sha ? pull.merge_base_sha : pull.base_sha; return withStore(repo.artifacts_name, (baseStore) => withStore( headRepo.artifacts_name, async (headStore) => (await compareCommits(baseStore, base, headStore, pull.head_sha)).commits, ), ); }); export const getPullFiles = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(pullInput.extend({ commit: z.string().length(40).optional(), since: z.string().length(40).optional() })) .handler(async ({ data, context }) => { await limitHeavy(context.viewer); const { repo, pull, headRepo } = await requirePull(data.owner, data.repo, data.number, context.viewer, "read"); const { diff, commits, range } = await pullDiff(pull, repo, headRepo, { commit: data.commit, since: data.since }); const lastReview = context.viewer ? await first<{ commit_sha: string }>( "SELECT commit_sha FROM reviews WHERE issue_id = ? AND author_id = ? ORDER BY created_at DESC LIMIT 1", pull.issue_id, context.viewer.id, ) : null; const comments = await all<{ id: string; author_id: string; path: string; line: number; side: "LEFT" | "RIGHT"; start_line: number | null; start_side: "LEFT" | "RIGHT" | null; body: string; commit_sha: string; suggestion_applied_sha: string | null; in_reply_to: string | null; resolved: number; created_at: number; }>("SELECT * FROM review_comments WHERE issue_id = ? ORDER BY created_at", pull.issue_id); const [people, reactions] = await Promise.all([ accountsById(comments.map((comment) => comment.author_id)), reactionSummaries( "review_comment", comments.map((comment) => comment.id), context.viewer?.id ?? null, ), ]); return { diff, commits, range, lastReviewSha: lastReview?.commit_sha ?? null, headSha: pull.head_sha, comments: comments.map((comment) => ({ id: comment.id, author: accountOrGhost(people, comment.author_id), path: comment.path, line: comment.line, side: comment.side, startLine: comment.start_line, startSide: comment.start_side, hasSuggestion: parseSuggestion(comment.body) !== null, suggestionApplied: comment.suggestion_applied_sha !== null, html: renderMarkdown(comment.body, { owner: repo.owner_name, repo: repo.name, ref: pull.head_sha, basePath: "", }), outdated: comment.commit_sha !== pull.head_sha, inReplyTo: comment.in_reply_to, resolved: comment.resolved === 1, createdAt: comment.created_at, reactions: reactions.get(comment.id) ?? [], })), }; }); export const mergePull = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ method: z.enum(["merge", "squash", "rebase"]), message: z.string().max(10_000), deleteBranch: z.boolean().default(false), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission, pull, headRepo } = await requirePull(data.owner, data.repo, data.number, viewer, "write"); const gate = await mergeGate(pull, permission); if (!gate.allowed) throw forbidden(gate.blockers.join("; ") || "Merging is blocked"); const author = await first<{ name: string; display_name: string | null; email: string }>( "SELECT a.name, a.display_name, u.email FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.id = ?", pull.author_id, ); const defaultMessage = data.method === "squash" ? `${pull.title} (#${pull.number})` : `Merge pull request #${pull.number} from ${headRepo.owner_name}/${pull.head_ref}\n\n${pull.title}`; try { const sha = await performMerge({ pull, method: data.method, message: data.message.trim() || defaultMessage, merger: { id: viewer.id, name: viewer.displayName ?? viewer.name, email: viewer.email }, author: { name: author?.display_name ?? author?.name ?? viewer.name, email: author?.email ?? viewer.email }, }); await recordActivity(viewer.id, repo.id, "pull_merged", { number: pull.number, title: pull.title }); await emitWebhook(repo.id, "pull_request", { action: "closed", number: pull.number, pull_request: { number: pull.number, merged: true, merge_commit_sha: sha, html_url: pullUrl(repo, pull.number), }, sender: { login: viewer.name }, }); if (data.deleteBranch && headRepo.id === repo.id && pull.head_ref !== repo.default_branch) { await deleteRef(repo, `refs/heads/${pull.head_ref}`, pull.head_sha).catch((error) => console.error("Branch delete failed", error), ); await addIssueEvent(pull.issue_id, viewer.id, "head_ref_deleted", { ref: pull.head_ref }); } return { sha }; } catch (error) { if (error instanceof MergeError) throw conflict(error.message); throw error; } }); export const updatePull = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(pullInput.extend({ base: z.string().optional(), draft: z.boolean().optional() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo, permission, pull } = await requirePull(data.owner, data.repo, data.number, viewer, "read"); if (pull.author_id !== viewer.id && permission === "read") throw forbidden(); if (data.base && data.base !== pull.base_ref) { const sha = branchSha(await listRefs(repo), data.base); if (!sha) throw badRequest("Base branch not found"); await run( "UPDATE pull_requests SET base_ref = ?, base_sha = ?, mergeable = 'unknown' WHERE issue_id = ?", data.base, sha, pull.issue_id, ); await addIssueEvent(pull.issue_id, viewer.id, "base_changed", { from: pull.base_ref, to: data.base }); } if (data.draft !== undefined && data.draft !== (pull.draft === 1)) { await run("UPDATE pull_requests SET draft = ? WHERE issue_id = ?", data.draft ? 1 : 0, pull.issue_id); await addIssueEvent(pull.issue_id, viewer.id, data.draft ? "converted_to_draft" : "ready_for_review", {}); if (!data.draft) afterPullChange(pull.issue_id, "ready_for_review", viewer); } return { ok: true }; }); export const recheckMergeability = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(pullInput) .handler(async ({ data, context }) => { await limitHeavy(requireViewer(context.viewer)); const { pull } = await requirePull(data.owner, data.repo, data.number, context.viewer, "read"); return { mergeable: await checkMergeability(pull) }; });