import { pullInput, 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, first } from "~/server/db"; import { loadRepo, repoPermission, requireRepoAccess } from "~/server/repos"; import { branchSha, listRefs } from "~/server/git/refs"; import { withStore } from "~/server/git/store"; import { compareCommits } from "~/server/git/history"; import { checkMergeability, mergeGate } from "~/server/pulls"; import { findForkHead, mergePullRequest, openPull } from "~/server/pull-create"; import { updatePull as updatePullRequest } from "~/server/pull-automation"; 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 { 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 findForkHead(repo, data.head.slice(0, separator)); } if (!resolvedHeadRepo || (await repoPermission(resolvedHeadRepo, viewer)) === "none") throw notFoundError("Head repository not found"); const number = await openPull({ repo, headRepo: resolvedHeadRepo, headRef, base: data.base, title: data.title, body: data.body, draft: data.draft, 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), expectedHead: z.string().length(40), timezone: z .string() .regex(/^[+-]\d{4}$/) .optional(), }), ) .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 sha = await mergePullRequest({ repo, permission, pull, headRepo, viewer, method: data.method, message: data.message, deleteBranch: data.deleteBranch, expectedHead: data.expectedHead, timezone: data.timezone, }); return { sha }; }); 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 access = await requirePull(data.owner, data.repo, data.number, viewer, "read"); await updatePullRequest(access, viewer, data); 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) }; });