import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { pullInput, requirePull, requirePullMutation } from "~/server/pull-access"; import { limitHeavy, limitWrite } from "~/server/limits"; import { requireRepoAccess } from "~/server/repos"; import { addIssueEvent, MergeError } from "~/server/pulls"; import { headBranchConflicts, requireHeadPush, updateHeadBranch } from "~/server/pull-merge"; import { applySuggestions, MAX_BATCHED_SUGGESTIONS } from "~/server/suggestions"; import { linkIssue, unlinkIssue } from "~/server/pull-links"; import { loadIssue } from "~/server/issues"; import { readPullTemplate } from "~/server/pull-templates"; import { badRequest, conflict, notFoundError } from "~/server/errors"; import { repoRef } from "~/lib/validation"; import { setCodeOwnerRequirement as setRuleCodeOwnerRequirement } from "~/server/protection-write"; import { setAutoMerge as setPullAutoMerge, setMergeQueue as setPullMergeQueue } from "~/server/pull-automation"; import { dismissReview as dismissPullReview, rerequestPullReview as rerequestReviewFor } from "~/server/reviews-write"; const MAX_RESOLVED_FILE_CHARS = 1024 * 1024; function rethrowMerge(error: unknown): never { if (error instanceof MergeError) throw conflict(error.message); throw error; } export const updatePullBranch = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(pullInput) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { pull, headRepo } = await requirePullMutation(data.owner, data.repo, data.number, viewer, "read"); if (pull.state !== "open") throw badRequest("Pull request is not open"); await requireHeadPush(headRepo, pull.head_ref, viewer); const outcome = await updateHeadBranch(pull, viewer).catch(rethrowMerge); if (outcome.conflicts) return { sha: null, conflicts: outcome.conflicts.length }; return { sha: outcome.sha, conflicts: 0 }; }); export const getPullConflicts = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(pullInput) .handler(async ({ data, context }) => { await limitHeavy(context.viewer); const { pull, headRepo } = await requirePull(data.owner, data.repo, data.number, context.viewer, "read"); const result = await headBranchConflicts(pull).catch(rethrowMerge); const canPush = context.viewer !== null && pull.state === "open" && (await requireHeadPush(headRepo, pull.head_ref, context.viewer).then( () => true, () => false, )); return { available: result !== null, resolvable: result?.resolvable ?? false, baseRef: pull.base_ref, headRef: pull.head_ref, headSha: pull.head_sha, canPush, conflicts: result?.conflicts ?? [], }; }); export const resolvePullConflicts = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ expectedHead: z.string().length(40), resolutions: z .array( z.object({ path: z.string().min(1), content: z.string().max(MAX_RESOLVED_FILE_CHARS), }), ) .min(1) .max(50), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { pull, headRepo } = await requirePullMutation(data.owner, data.repo, data.number, viewer, "read"); if (pull.head_sha !== data.expectedHead) throw conflict("The head branch changed; reload to see new conflicts"); await requireHeadPush(headRepo, pull.head_ref, viewer); const outcome = await updateHeadBranch(pull, viewer, data.resolutions).catch(rethrowMerge); if (outcome.conflicts) throw conflict(`${outcome.conflicts.length} file(s) still have unresolved conflicts`); return { sha: outcome.sha }; }); export const commitSuggestions = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ commentIds: z.array(z.string().min(1)).min(1).max(MAX_BATCHED_SUGGESTIONS), message: z.string().trim().max(10_000).default(""), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { pull, headRepo } = await requirePullMutation(data.owner, data.repo, data.number, viewer, "read"); if (pull.state !== "open") throw badRequest("Pull request is not open"); await requireHeadPush(headRepo, pull.head_ref, viewer); const fallback = data.commentIds.length === 1 ? "Apply suggestion from code review" : "Apply suggestions from code review"; const sha = await applySuggestions({ pull, headRepo, viewer, commentIds: data.commentIds, message: data.message || fallback, }).catch(rethrowMerge); return { sha }; }); const mergeMethod = z.enum(["merge", "squash", "rebase"]); export const setAutoMerge = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ enabled: z.boolean(), method: mergeMethod.default("merge"), message: z.string().max(10_000).default(""), expectedHead: z.string().length(40).optional(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requirePullMutation(data.owner, data.repo, data.number, viewer, "write"); await setPullAutoMerge(access, viewer, data); return { ok: true }; }); export const setMergeQueue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ queued: z.boolean(), method: z.enum(["merge", "squash"]).default("merge"), expectedHead: z.string().length(40).optional(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requirePullMutation(data.owner, data.repo, data.number, viewer, "write"); await setPullMergeQueue(access, viewer, data); return { ok: true }; }); export const dismissReview = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ reviewId: z.string().min(1), reason: z.string().trim().min(1).max(1000), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requirePullMutation(data.owner, data.repo, data.number, viewer, "write"); await dismissPullReview(access, viewer, data); return { ok: true }; }); export const rerequestPullReview = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(pullInput.extend({ userId: z.string().min(1) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const access = await requirePullMutation(data.owner, data.repo, data.number, viewer, "read"); await rerequestReviewFor(access, viewer, data.userId); return { ok: true }; }); export const setLinkedIssue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( pullInput.extend({ issueNumber: z.number().int().positive(), linked: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, pull } = await requirePullMutation(data.owner, data.repo, data.number, viewer, "write"); const issue = await loadIssue(repo.id, data.issueNumber); if (!issue || issue.is_pull === 1) throw notFoundError("Issue not found"); if (data.linked) await linkIssue(pull.issue_id, issue.id, viewer.id); else await unlinkIssue(pull.issue_id, issue.id); await addIssueEvent(pull.issue_id, viewer.id, data.linked ? "issue_linked" : "issue_unlinked", { number: issue.number, }); return { ok: true }; }); export const getPullTemplate = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ base: z.string().min(1) })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); return { template: await readPullTemplate(repo, data.base) }; }); export const setCodeOwnerRequirement = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ protectionId: z.string().min(1), enabled: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); await setRuleCodeOwnerRequirement(repo.id, data.protectionId, data.enabled); return { ok: true }; });