651 lines · 21.2 KB · TypeScript
RawDownload
import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";import type { PullRequestReviewPosition } from "@t3tools/contracts";import * as Schema from "effect/Schema"; const ReviewCommentSelectionSchema = Schema.Struct({  start: Schema.Number,  side: Schema.Literals(["additions", "deletions"]),  end: Schema.Number,  endSide: Schema.Literals(["additions", "deletions"]),});type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; export const ReviewCommentContextSchema = Schema.Struct({  id: Schema.String,  sectionId: Schema.String,  sectionTitle: Schema.String,  filePath: Schema.String,  startIndex: Schema.Number,  endIndex: Schema.Number,  rangeLabel: Schema.String,  text: Schema.String,  diff: Schema.String,  fenceLanguage: Schema.optional(Schema.String),  selection: Schema.optional(ReviewCommentSelectionSchema),}); export interface ReviewCommentContext {  readonly id: string;  readonly sectionId: string;  readonly sectionTitle: string;  readonly filePath: string;  readonly startIndex: number;  readonly endIndex: number;  readonly rangeLabel: string;  readonly text: string;  readonly diff: string;  readonly fenceLanguage?: string | undefined;  readonly selection?: ReviewCommentSelection | undefined;} interface DiffReviewLine {  readonly change: "context" | "add" | "delete";  readonly oldLineNumber: number | null;  readonly newLineNumber: number | null;  readonly content: string;} export type ReviewCommentMessageSegment =  | {      readonly kind: "text";      readonly id: string;      readonly text: string;    }  | {      readonly kind: "review-comment";      readonly comment: ReviewCommentContext;    }; const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; function escapeReviewCommentAttribute(value: string): string {  return value    .replace(/&/g, "&amp;")    .replace(/"/g, "&quot;")    .replace(/</g, "&lt;")    .replace(/>/g, "&gt;");} function unescapeReviewCommentAttribute(value: string): string {  return value    .replace(/&lt;/g, "<")    .replace(/&gt;/g, ">")    .replace(/&quot;/g, '"')    .replace(/&amp;/g, "&");} function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {  const attributes: Record<string, string> = {};  for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {    attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");  }  return attributes;} function readNonNegativeInteger(value: string | undefined): number | null {  if (value === undefined || !/^\d+$/.test(value)) {    return null;  }  return Number(value);} function extractReviewCommentBody(rawBody: string): {  text: string;  language: string;  contents: string;} {  const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN));  const match = matches.at(-1);  const fenceIndex = match?.index;  return {    text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(),    language: match?.[2]?.trim() || "diff",    contents: match?.[3] ?? "",  };} function parseReviewCommentContext(  rawAttributes: string,  rawBody: string,  index: number,): ReviewCommentContext | null {  const attributes = readReviewCommentAttributes(rawAttributes);  const startIndex = readNonNegativeInteger(attributes.startIndex);  const endIndex = readNonNegativeInteger(attributes.endIndex);  const filePath = attributes.filePath?.trim();  const sectionId = attributes.sectionId?.trim();  if (!filePath || !sectionId || startIndex === null || endIndex === null) {    return null;  }  const body = extractReviewCommentBody(rawBody);   return {    id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,    sectionId,    sectionTitle: attributes.sectionTitle?.trim() || "Review",    filePath,    startIndex: Math.min(startIndex, endIndex),    endIndex: Math.max(startIndex, endIndex),    rangeLabel: attributes.rangeLabel?.trim() || "line",    text: body.text,    diff: body.contents,    fenceLanguage: body.language,  };} export function parseReviewCommentMessageSegments(  value: string,): ReadonlyArray<ReviewCommentMessageSegment> {  const segments: ReviewCommentMessageSegment[] = [];  let cursor = 0;  let parsedCommentIndex = 0;   for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {    const matchIndex = match.index ?? 0;    const beforeText = value.slice(cursor, matchIndex);    if (beforeText.length > 0) {      segments.push({        kind: "text",        id: `review-comment-text:${cursor}`,        text: beforeText,      });    }     const comment = parseReviewCommentContext(match[1] ?? "", match[2] ?? "", parsedCommentIndex);    if (comment) {      segments.push({ kind: "review-comment", comment });      parsedCommentIndex += 1;    } else {      segments.push({        kind: "text",        id: `review-comment-invalid:${matchIndex}`,        text: match[0],      });    }     cursor = matchIndex + match[0].length;  }   const rest = value.slice(cursor);  if (rest.length > 0) {    segments.push({      kind: "text",      id: `review-comment-text:${cursor}`,      text: rest,    });  }   return segments;} export function formatReviewCommentFence(language: string, contents: string): string {  const longestBacktickRun = Math.max(    0,    ...Array.from(contents.matchAll(/`+/g), (match) => match[0].length),  );  const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));  return [`${fence}${language}`, contents.trimEnd(), fence].join("\n");} /** * Keeps a comment's own words from closing the block they travel in. The parser ends an * attachment at the first `</review_comment>`, so text carrying one would spill the rest of * itself into the prompt — and could open a forged attachment naming any file it liked. Only * ever the local reader's words before, but a pull request's review bodies come from whoever * wrote them. */function neutralizeReviewCommentTags(text: string): string {  return text.replace(/<(?=\/?review_comment\b)/giu, "&lt;");} export function formatReviewCommentContext(comment: ReviewCommentContext): string {  return [    [      "<review_comment",      ` sectionId="${escapeReviewCommentAttribute(comment.sectionId)}"`,      ` sectionTitle="${escapeReviewCommentAttribute(comment.sectionTitle)}"`,      ` filePath="${escapeReviewCommentAttribute(comment.filePath)}"`,      ` startIndex="${comment.startIndex}"`,      ` endIndex="${comment.endIndex}"`,      ` rangeLabel="${escapeReviewCommentAttribute(comment.rangeLabel)}"`,      ">",    ].join(""),    neutralizeReviewCommentTags(comment.text.trim()),    formatReviewCommentFence(comment.fenceLanguage ?? "diff", comment.diff),    "</review_comment>",  ].join("\n");} export function appendReviewCommentsToPrompt(  prompt: string,  comments: ReadonlyArray<ReviewCommentContext>,): string {  const blocks = comments.map(formatReviewCommentContext);  if (blocks.length === 0) return prompt;  const trimmedPrompt = prompt.trim();  return trimmedPrompt.length > 0    ? `${trimmedPrompt}\n\n${blocks.join("\n\n")}`    : blocks.join("\n\n");} export function buildFileReviewComment(input: {  id: string;  filePath: string;  startLine: number;  endLine: number;  text: string;  contents: string;}): ReviewCommentContext {  const startLine = Math.max(1, Math.min(input.startLine, input.endLine));  const endLine = Math.max(startLine, Math.max(input.startLine, input.endLine));  const selectedLines = input.contents.split("\n").slice(startLine - 1, endLine);  return {    id: input.id,    sectionId: `file:${input.filePath}`,    sectionTitle: "File comment",    filePath: input.filePath,    startIndex: startLine - 1,    endIndex: endLine - 1,    rangeLabel: startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`,    text: input.text.trim(),    diff: selectedLines.join("\n"),    fenceLanguage: inferReviewCommentFenceLanguage(input.filePath),  };} export function inferReviewCommentFenceLanguage(filePath: string): string {  const normalizedPath = filePath.replaceAll("\\", "/");  const fileName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1).toLowerCase();  const extensionIndex = fileName.lastIndexOf(".");  if (extensionIndex > 0 && extensionIndex < fileName.length - 1) {    return fileName.slice(extensionIndex + 1);  }  if (fileName.startsWith(".") && fileName.length > 1) {    return fileName.slice(1);  }  return "text";} function stripTrailingNewline(value: string): string {  return value.endsWith("\n") ? value.slice(0, -1) : value;} function buildDiffReviewLines(  fileDiff: FileDiffMetadata,  includeExpandedContext: boolean,  slice?: { readonly startIndex: number; readonly endIndex: number },): ReadonlyArray<DiffReviewLine> {  const rows: DiffReviewLine[] = [];  let rowIndex = 0;  let oldContextStart = 1;  let newContextStart = 1;  const pushRow = (row: DiffReviewLine) => {    if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) {      rows.push(row);    }    rowIndex += 1;  };  const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => {    const count = Math.max(0, lineCount);    const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0;    const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1;    for (let offset = firstOffset; offset <= lastOffset; offset += 1) {      rows.push({        change: "context",        oldLineNumber: oldStart + offset,        newLineNumber: newStart + offset,        content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""),      });    }    rowIndex += count;  };   for (const hunk of fileDiff.hunks) {    if (includeExpandedContext) {      const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);      const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);      const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart);      pushContextGap(oldContextStart, newContextStart, contextLines);    }     let oldLineNumber = hunk.deletionStart;    let newLineNumber = hunk.additionStart;    let deletionLineIndex = hunk.deletionLineIndex;    let additionLineIndex = hunk.additionLineIndex;     for (const segment of hunk.hunkContent) {      if (segment.type === "context") {        for (let index = 0; index < segment.lines; index += 1) {          pushRow({            change: "context",            oldLineNumber,            newLineNumber,            content: stripTrailingNewline(              fileDiff.additionLines[additionLineIndex] ??                fileDiff.deletionLines[deletionLineIndex] ??                "",            ),          });          oldLineNumber += 1;          newLineNumber += 1;          deletionLineIndex += 1;          additionLineIndex += 1;        }        continue;      }       for (let index = 0; index < segment.deletions; index += 1) {        pushRow({          change: "delete",          oldLineNumber,          newLineNumber: null,          content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""),        });        oldLineNumber += 1;        deletionLineIndex += 1;      }       for (let index = 0; index < segment.additions; index += 1) {        pushRow({          change: "add",          oldLineNumber: null,          newLineNumber,          content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""),        });        newLineNumber += 1;        additionLineIndex += 1;      }    }     oldContextStart = hunk.deletionStart + hunk.deletionCount;    newContextStart = hunk.additionStart + hunk.additionCount;    if (hunk.deletionCount === 0) oldContextStart += 1;    if (hunk.additionCount === 0) newContextStart += 1;  }   if (includeExpandedContext) {    const trailingLines = Math.min(      fileDiff.deletionLines.length - oldContextStart + 1,      fileDiff.additionLines.length - newContextStart + 1,    );    pushContextGap(oldContextStart, newContextStart, trailingLines);  }   return rows;} function getDiffReviewSelectionPoint(  line: DiffReviewLine,): { lineNumber: number; side: SelectionSide } | null {  if (line.change === "delete" && line.oldLineNumber !== null) {    return { lineNumber: line.oldLineNumber, side: "deletions" };  }  if (line.newLineNumber !== null) {    return { lineNumber: line.newLineNumber, side: "additions" };  }  if (line.oldLineNumber !== null) {    return { lineNumber: line.oldLineNumber, side: "deletions" };  }  return null;} export function restoreDiffReviewCommentRange(  fileDiff: FileDiffMetadata,  comment: ReviewCommentContext,): SelectedLineRange | null {  if (comment.selection) return comment.selection;   const includeExpandedContext = !fileDiff.isPartial;  const startLine = buildDiffReviewLines(fileDiff, includeExpandedContext, {    startIndex: comment.startIndex,    endIndex: comment.startIndex,  })[0];  const endLine =    comment.endIndex === comment.startIndex      ? startLine      : buildDiffReviewLines(fileDiff, includeExpandedContext, {          startIndex: comment.endIndex,          endIndex: comment.endIndex,        })[0];  if (!startLine || !endLine) return null;  const start = getDiffReviewSelectionPoint(startLine);  const end = getDiffReviewSelectionPoint(endLine);  if (!start || !end) return null;  return {    start: start.lineNumber,    side: start.side,    end: end.lineNumber,    endSide: end.side,  };} function findDiffReviewLineIndex(  fileDiff: FileDiffMetadata,  lineNumber: number,  side: SelectionSide | undefined,  includeExpandedContext = !fileDiff.isPartial,): number {  const findOnSide = (selectedSide: "left" | "right") => {    let rowIndex = 0;    let oldContextStart = 1;    let newContextStart = 1;    const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => {      const count = Math.max(0, lineCount);      const selectedStart = selectedSide === "left" ? oldStart : newStart;      const offset = lineNumber - selectedStart;      return offset >= 0 && offset < count ? rowIndex + offset : -1;    };     for (const hunk of fileDiff.hunks) {      if (includeExpandedContext) {        const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);        const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);        const contextLines = Math.min(          oldContextEnd - oldContextStart,          newContextEnd - newContextStart,        );        const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines);        if (contextIndex >= 0) return contextIndex;        rowIndex += Math.max(0, contextLines);      }       let oldLineNumber = hunk.deletionStart;      let newLineNumber = hunk.additionStart;      for (const segment of hunk.hunkContent) {        if (segment.type === "context") {          const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines);          if (contextIndex >= 0) return contextIndex;          rowIndex += segment.lines;          oldLineNumber += segment.lines;          newLineNumber += segment.lines;          continue;        }         if (          selectedSide === "left" &&          lineNumber >= oldLineNumber &&          lineNumber < oldLineNumber + segment.deletions        ) {          return rowIndex + lineNumber - oldLineNumber;        }        rowIndex += segment.deletions;        oldLineNumber += segment.deletions;         if (          selectedSide === "right" &&          lineNumber >= newLineNumber &&          lineNumber < newLineNumber + segment.additions        ) {          return rowIndex + lineNumber - newLineNumber;        }        rowIndex += segment.additions;        newLineNumber += segment.additions;      }       oldContextStart = hunk.deletionStart + hunk.deletionCount;      newContextStart = hunk.additionStart + hunk.additionCount;      if (hunk.deletionCount === 0) oldContextStart += 1;      if (hunk.additionCount === 0) newContextStart += 1;    }     if (!includeExpandedContext) return -1;    const trailingLines = Math.min(      fileDiff.deletionLines.length - oldContextStart + 1,      fileDiff.additionLines.length - newContextStart + 1,    );    return findContextIndex(oldContextStart, newContextStart, trailingLines);  }; 

Lines 1–500 of 651.