import { placeholders } from "./db"; import { repoDb, stmt } from "./scoped-db"; import { withStore } from "./git/store"; import { commitChanges, PushRejectedError } from "./git/writer"; import { sanitizeIdent, signatureNow } from "./git/objects"; import { splitLines } from "./git/merge3"; import { triggerPush } from "./events/push-trigger"; import { accountsById } from "./people"; import { MergeError } from "./pulls"; import type { PullRow } from "./pulls"; import type { RepoRow, Viewer } from "~/lib/types"; export const MAX_BATCHED_SUGGESTIONS = 20; const MAX_SUGGESTED_FILE_BYTES = 2 * 1024 * 1024; const SUGGESTION_BLOCK = /```suggestion[^\n]*\n([\s\S]*?)^```/m; const decoder = new TextDecoder("utf-8", { fatal: true }); const encoder = new TextEncoder(); /** Extracts the replacement text of the first ```suggestion block, or null when there is none. */ export function parseSuggestion(body: string): string | null { const match = SUGGESTION_BLOCK.exec(body.replace(/\r\n/g, "\n")); return match ? match[1] : null; } type SuggestionRow = { id: string; author_id: string; path: string; line: number; start_line: number | null; start_side: "LEFT" | "RIGHT" | null; side: "LEFT" | "RIGHT"; in_reply_to: string | null; commit_sha: string; body: string; suggestion_applied_sha: string | null; }; type Edit = { start: number; end: number; replacement: string }; function applyEdits(text: string, edits: Edit[]): string { const lines = splitLines(text); const sorted = [...edits].sort((left, right) => right.start - left.start); for (let index = 1; index < sorted.length; index++) { if (sorted[index].end >= sorted[index - 1].start) throw new MergeError("Suggestions overlap; apply them separately"); } for (const edit of sorted) { if (edit.start < 1 || edit.end > lines.length) throw new MergeError("Suggestion is outside the file"); const replaced = lines.slice(edit.start - 1, edit.end); const keepsNewline = replaced.at(-1)?.endsWith("\n") ?? true; const replacement = edit.replacement.length === 0 ? [] : splitLines(edit.replacement); const last = replacement.at(-1); if (last !== undefined && !keepsNewline && last.endsWith("\n")) replacement[replacement.length - 1] = last.slice(0, -1); lines.splice(edit.start - 1, edit.end - edit.start + 1, ...replacement); } return lines.join(""); } /** Commits one or more review suggestions to the head branch in a single in-Worker commit. */ export async function applySuggestions(input: { pull: PullRow; headRepo: RepoRow; viewer: Viewer; commentIds: string[]; message: string; }): Promise { const { pull, headRepo, viewer } = input; const ids = [...new Set(input.commentIds)].slice(0, MAX_BATCHED_SUGGESTIONS); const db = repoDb(pull.repo_id); const rows = await db.all( `SELECT id, author_id, path, line, start_line, start_side, side, in_reply_to, commit_sha, body, suggestion_applied_sha FROM review_comments WHERE issue_id = ? AND id IN (${placeholders(ids.length)})`, pull.issue_id, ...ids, ); if (rows.length !== ids.length) throw new MergeError("Some suggestions no longer exist"); const byPath = new Map(); for (const row of rows) { const replacement = parseSuggestion(row.body); if (replacement === null) throw new MergeError("Comment has no suggestion"); if (row.suggestion_applied_sha) throw new MergeError("Suggestion was already applied"); if (row.in_reply_to !== null) throw new MergeError("Only the first comment of a thread can be applied"); if (row.side !== "RIGHT" || (row.start_side ?? "RIGHT") !== "RIGHT" || row.commit_sha !== pull.head_sha) { throw new MergeError("Suggestion is outdated; the code changed since it was made"); } const edits = byPath.get(row.path) ?? []; edits.push({ start: row.start_line ?? row.line, end: row.line, replacement, }); byPath.set(row.path, edits); } const people = await accountsById(rows.map((row) => row.author_id)); const coAuthors = [...new Set(rows.map((row) => row.author_id))] .filter((id) => id !== viewer.id) .flatMap((id) => { const person = people.get(id); if (!person) return []; const name = sanitizeIdent(person.displayName ?? "") || person.name; return [`Co-authored-by: ${name} <${person.name}@users.noreply>`]; }); const message = coAuthors.length ? `${input.message}\n\n${coAuthors.join("\n")}` : input.message; const sha = await withStore(headRepo.artifacts_name, async (store) => { const head = await store.commit(pull.head_sha); if (!head) throw new MergeError("Head commit is missing"); const changes = []; for (const [path, edits] of byPath) { const entry = await store.entryAtPath(head.treeSha, path); if (!entry || entry.type !== "blob") throw new MergeError(`${path} no longer exists`); const bytes = await store.blob(entry.sha); if (!bytes || bytes.byteLength > MAX_SUGGESTED_FILE_BYTES) throw new MergeError(`${path} is too large`); let text: string; try { text = decoder.decode(bytes); } catch { throw new MergeError(`${path} is not a text file`); } changes.push({ path, mode: entry.mode, content: encoder.encode(applyEdits(text, edits)), }); } return commitChanges({ repo: headRepo, store, branch: pull.head_ref, parentSha: pull.head_sha, changes, message, author: signatureNow(viewer.displayName ?? viewer.name, viewer.email), }); }).catch((error: unknown) => { if (error instanceof PushRejectedError) throw new MergeError("The head branch changed; reload and try again"); throw error; }); await db.batch( rows.map((row) => stmt("UPDATE review_comments SET suggestion_applied_sha = ? WHERE id = ?", sha, row.id)), ); await triggerPush({ source: "internal", repoId: headRepo.id, artifactsName: headRepo.artifacts_name, ref: `refs/heads/${pull.head_ref}`, before: pull.head_sha, after: sha, pusherId: viewer.id, }); return sha; }