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;} 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_DIFF_FENCE_PATTERN = /```diff\s*\n([\s\S]*?)\n```/; function unescapeReviewCommentAttribute(value: string): string { return value .replace(/</g, "<") .replace(/"/g, '"') .replace(/&/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 extractReviewCommentText(rawBody: string): string { const fenceIndex = rawBody.indexOf("```diff"); const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody; return commentBody.trim();} function extractReviewCommentDiff(rawBody: string): string { return rawBody.match(REVIEW_COMMENT_DIFF_FENCE_PATTERN)?.[1]?.trim() ?? "";} 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; } 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: extractReviewCommentText(rawBody), diff: extractReviewCommentDiff(rawBody), };} 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 hasReviewCommentMessageSegments(value: string): boolean { return parseReviewCommentMessageSegments(value).some( (segment) => segment.kind === "review-comment", );} export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string { const diff = comment.diff.trim(); if (diff.length === 0) { return ""; } if (diff.startsWith("diff --git ")) { return diff; } const normalizedPath = comment.filePath.replaceAll("\\", "/"); return [ `diff --git a/${normalizedPath} b/${normalizedPath}`, `--- a/${normalizedPath}`, `+++ b/${normalizedPath}`, diff, ].join("\n");}
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;
}
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_DIFF_FENCE_PATTERN = /```diff\s*\n([\s\S]*?)\n```/;
function unescapeReviewCommentAttribute(value: string): string {
return value
.replace(/</g, "<")
.replace(/"/g, '"')
.replace(/&/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 extractReviewCommentText(rawBody: string): string {
const fenceIndex = rawBody.indexOf("```diff");
const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
return commentBody.trim();
}
function extractReviewCommentDiff(rawBody: string): string {
return rawBody.match(REVIEW_COMMENT_DIFF_FENCE_PATTERN)?.[1]?.trim() ?? "";
}
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;
}
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: extractReviewCommentText(rawBody),
diff: extractReviewCommentDiff(rawBody),
};
}
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 hasReviewCommentMessageSegments(value: string): boolean {
return parseReviewCommentMessageSegments(value).some(
(segment) => segment.kind === "review-comment",
);
}
export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string {
const diff = comment.diff.trim();
if (diff.length === 0) {
return "";
}
if (diff.startsWith("diff --git ")) {
return diff;
}
const normalizedPath = comment.filePath.replaceAll("\\", "/");
return [
`diff --git a/${normalizedPath} b/${normalizedPath}`,
`--- a/${normalizedPath}`,
`+++ b/${normalizedPath}`,
diff,
].join("\n");
}