authorgravatar for Taras.Fomin@gmail.comTaras <Taras.Fomin@gmail.com> 2026-08-15 14:37:02+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 13:37:02+02:00
logd94fbda344398bd266b9f6645e531eb17d3c9e4e
tree2d4908efb3c0ef22e4365cce1245ecb1a34b5466
parentb7dbbbaf6c394621cba57cf58dfcc1845f445ef6
signature Signed by PGP key B5690EEEBB952194

fix(gitlab): submit review comments on context lines (#6348)


13 files changed, 338 insertions(+), 54 deletions(-)

apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts+7-1
......@@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => {
776776 number: 7,
777777 verdict: "request-changes",
778778 body: "Two things.",
779 comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }],
779 comments: [
780 {
781 path: "src/a.ts",
782 position: { kind: "deleted", oldLine: 12 },
783 body: "why remove?",
784 },
785 ],
780786 });
781787
782788 expect(callAt(0).url).toContain("/pullrequests/7/comments");
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+15-1
......@@ -12,6 +12,7 @@ import type {
1212 PullRequestMergeMethod,
1313 PullRequestMergeability,
1414 PullRequestReviewCommentDraft,
15 PullRequestReviewPosition,
1516 PullRequestReviewThread,
1617 PullRequestReviewVerdict,
1718 PullRequestReviewerCandidateList,
......@@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string {
364365 }
365366}
366367
368function bitbucketReviewPosition(
369 position: PullRequestReviewPosition,
370): { readonly from: number } | { readonly to: number } {
371 switch (position.kind) {
372 case "added":
373 return { to: position.newLine };
374 case "deleted":
375 return { from: position.oldLine };
376 case "context":
377 return position.side === "left" ? { from: position.oldLine } : { to: position.newLine };
378 }
379}
380
367381export const make = Effect.gen(function* () {
368382 const bitbucket = yield* BitbucketApi.BitbucketApi;
369383
......@@ -794,7 +808,7 @@ export const make = Effect.gen(function* () {
794808 content: { raw: comment.body },
795809 inline: {
796810 path: comment.path,
797 ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }),
811 ...bitbucketReviewPosition(comment.position),
798812 },
799813 }),
800814 }),
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+1-1
......@@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
16241624 number: 7,
16251625 verdict: "approve",
16261626 body: "Looks right.",
1627 comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }],
1627 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
16281628 });
16291629
16301630 expect(callAt(0).args).toEqual([
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts+7-2
......@@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => {
10181018 verdict: "approve",
10191019 body: "Looks right.",
10201020 comments: [
1021 { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" },
1021 {
1022 path: "src/b.ts",
1023 oldPath: "src/a.ts",
1024 position: { kind: "deleted", oldLine: 4 },
1025 body: "why remove?",
1026 },
10221027 ],
10231028 });
10241029
......@@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => {
11971202 number: 7,
11981203 verdict: "comment",
11991204 body: "",
1200 comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }],
1205 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
12011206 }),
12021207 );
12031208
apps/server/src/pullRequest/GitLabPullRequestCli.ts+18-3
......@@ -14,6 +14,7 @@ import type {
1414 PullRequestReaction,
1515 PullRequestReactionContent,
1616 PullRequestReviewCommentDraft,
17 PullRequestReviewPosition,
1718 PullRequestReviewThread,
1819 PullRequestReviewVerdict,
1920 PullRequestReviewerCandidateList,
......@@ -400,6 +401,22 @@ function projectPath(repository: string): string {
400401 return encodeURIComponent(repository.trim());
401402}
402403
404function gitLabReviewPositionLines(
405 position: PullRequestReviewPosition,
406):
407 | { readonly new_line: number }
408 | { readonly old_line: number }
409 | { readonly old_line: number; readonly new_line: number } {
410 switch (position.kind) {
411 case "added":
412 return { new_line: position.newLine };
413 case "deleted":
414 return { old_line: position.oldLine };
415 case "context":
416 return { old_line: position.oldLine, new_line: position.newLine };
417 }
418}
419
403420function stateParam(state: PullRequestListState): string {
404421 // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,
405422 // and it spans every state under `all`.
......@@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () {
13241341 // draft carries the name the file had before the change.
13251342 old_path: comment.oldPath ?? comment.path,
13261343 new_path: comment.path,
1327 ...(comment.side === "left"
1328 ? { old_line: comment.line }
1329 : { new_line: comment.line }),
1344 ...gitLabReviewPositionLines(comment.position),
13301345 },
13311346 }),
13321347 }),
apps/server/src/pullRequest/PullRequestService.test.ts+1-1
......@@ -1534,7 +1534,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>
15341534 number: 1,
15351535 verdict: "comment",
15361536 body: "",
1537 comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }],
1537 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }],
15381538 }),
15391539 );
15401540
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+10-2
......@@ -1147,8 +1147,16 @@ describe("review submission payload", () => {
11471147 verdict: "request-changes",
11481148 body: "Two things.",
11491149 comments: [
1150 { path: "src/a.ts", line: 12, side: "right", body: "rename this" },
1151 { path: "src/b.ts", line: 3, side: "left", body: "why remove?" },
1150 {
1151 path: "src/a.ts",
1152 position: { kind: "added", newLine: 12 },
1153 body: "rename this",
1154 },
1155 {
1156 path: "src/b.ts",
1157 position: { kind: "deleted", oldLine: 3 },
1158 body: "why remove?",
1159 },
11521160 ],
11531161 }),
11541162 ) as Record<string, unknown>;
apps/server/src/pullRequest/gitHubPullRequestJson.ts+18-2
......@@ -17,6 +17,7 @@ import type {
1717 PullRequestReactionContent,
1818 PullRequestReviewCommentDraft,
1919 PullRequestReviewDecision,
20 PullRequestReviewPosition,
2021 PullRequestReviewThread,
2122 PullRequestReviewVerdict,
2223 PullRequestReviewerCandidate,
......@@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St
933934 }
934935}`;
935936
937function gitHubReviewPosition(position: PullRequestReviewPosition): {
938 readonly line: number;
939 readonly side: "LEFT" | "RIGHT";
940} {
941 switch (position.kind) {
942 case "added":
943 return { line: position.newLine, side: "RIGHT" };
944 case "deleted":
945 return { line: position.oldLine, side: "LEFT" };
946 case "context":
947 return position.side === "left"
948 ? { line: position.oldLine, side: "LEFT" }
949 : { line: position.newLine, side: "RIGHT" };
950 }
951}
952
936953/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */
937954export function buildReviewSubmissionJson(input: {
938955 readonly verdict: PullRequestReviewVerdict;
......@@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: {
944961 body: input.body,
945962 comments: input.comments.map((comment) => ({
946963 path: comment.path,
947 line: comment.line,
948 side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const),
964 ...gitHubReviewPosition(comment.position),
949965 body: comment.body,
950966 })),
951967 });
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+33-12
......@@ -6,6 +6,7 @@ import type {
66 PullRequestDiffSide,
77 PullRequestOmittedFileStat,
88 PullRequestRef,
9 PullRequestReviewPosition,
910 PullRequestReviewThread,
1011} from "@t3tools/contracts";
1112import {
......@@ -43,7 +44,11 @@ import {
4344} from "~/lib/diffRendering";
4445import { cn } from "~/lib/utils";
4546import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents";
46import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext";
47import {
48 buildDiffReviewComment,
49 resolveDiffReviewPosition,
50 type ReviewCommentContext,
51} from "~/reviewCommentContext";
4752import { pullRequestEnvironment } from "~/state/pullRequests";
4853import { useEnvironmentQuery } from "~/state/query";
4954import { useAtomCommand } from "~/state/use-atom-command";
......@@ -129,8 +134,7 @@ interface DraftAnchor {
129134 readonly path: string;
130135 /** What the file was called before the change, for the hosts that resolve a position by both. */
131136 readonly oldPath: string | null;
132 readonly line: number;
133 readonly side: PullRequestDiffSide;
137 readonly position: PullRequestReviewPosition;
134138 /** The whole selection, which the comment collapses to one line but a question keeps. */
135139 readonly range: SelectedLineRange;
136140}
......@@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) {
148152 return side === "left" ? ("deletions" as const) : ("additions" as const);
149153}
150154
151function fromViewerSide(side: string | undefined): PullRequestDiffSide {
152 return side === "deletions" ? "left" : "right";
155function getReviewPositionAnchor(position: PullRequestReviewPosition): {
156 line: number;
157 side: PullRequestDiffSide;
158} {
159 switch (position.kind) {
160 case "added":
161 return { line: position.newLine, side: "right" };
162 case "deleted":
163 return { line: position.oldLine, side: "left" };
164 case "context":
165 return {
166 line: position.side === "left" ? position.oldLine : position.newLine,
167 side: position.side,
168 };
169 }
153170}
154171
155172/**
......@@ -442,10 +459,14 @@ export function PullRequestCodeTab({
442459 if (commit === null) {
443460 for (const comment of pendingComments) {
444461 if (comment.path !== path) continue;
445 groupAt(comment.side, comment.line).pending.push(comment);
462 const anchor = getReviewPositionAnchor(comment.position);
463 groupAt(anchor.side, anchor.line).pending.push(comment);
446464 }
447465 }
448 if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true;
466 if (draft?.fileKey === fileKey) {
467 const anchor = getReviewPositionAnchor(draft.position);
468 groupAt(anchor.side, anchor.line).draft = true;
469 }
449470
450471 const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles);
451472
......@@ -594,12 +615,13 @@ export function PullRequestCodeTab({
594615 // that silently lost its first line on the other hosts would be worse than one line.
595616 const path = resolveFileDiffPath(file);
596617 const previousPath = resolveFileDiffPreviousPath(file);
618 const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side);
619 if (position === null) return;
597620 setDraft({
598621 fileKey: item.id,
599622 path,
600623 oldPath: previousPath === path ? null : previousPath,
601 line: range.end,
602 side: fromViewerSide(range.endSide ?? range.side),
624 position,
603625 range,
604626 });
605627 },
......@@ -842,7 +864,7 @@ export function PullRequestCodeTab({
842864 {annotation.metadata.draft && draft ? (
843865 <DiffCommentAnnotation
844866 kind="draft"
845 rangeLabel={`${draft.path}:${draft.line}`}
867 rangeLabel={`${draft.path}:${getReviewPositionAnchor(draft.position).line}`}
846868 text=""
847869 submitLabel="Add to review"
848870 {...(onAskAboutSelection
......@@ -864,8 +886,7 @@ export function PullRequestCodeTab({
864886 id: nextPendingReviewCommentId(),
865887 path: draft.path,
866888 ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }),
867 line: draft.line,
868 side: draft.side,
889 position: draft.position,
869890 body,
870891 });
871892 setDraft(null);
apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts+1-1
......@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
33import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore";
44
55function comment(id: string, body = id): PendingReviewComment {
6 return { id, body, path: "src/app.ts", line: 1, side: "right" };
6 return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } };
77}
88
99describe("pull request review drafts", () => {
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+2-9
......@@ -6,17 +6,10 @@
66 * hosts that have no pending review of their own. That also means a draft lives only as long
77 * as the tab does, which is why this is deliberately not persisted.
88 */
9import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts";
9import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
1010import { create } from "zustand";
1111
12export interface PendingReviewComment {
13 readonly id: string;
14 readonly path: string;
15 /** The line in the file the comment's side names: the new file on the right, the old on the left. */
16 readonly line: number;
17 readonly side: PullRequestDiffSide;
18 readonly body: string;
19}
12export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };
2013
2114/**
2215 * A counter rather than anything derived from the comment: two remarks on one line can be the
apps/web/src/reviewCommentContext.ts+204-17
......@@ -1,6 +1,15 @@
11import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";
2import type { PullRequestReviewPosition } from "@t3tools/contracts";
23import * as Schema from "effect/Schema";
34
5const ReviewCommentSelectionSchema = Schema.Struct({
6 start: Schema.Number,
7 side: Schema.Literals(["additions", "deletions"]),
8 end: Schema.Number,
9 endSide: Schema.Literals(["additions", "deletions"]),
10});
11type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type;
12
413export const ReviewCommentContextSchema = Schema.Struct({
514 id: Schema.String,
615 sectionId: Schema.String,
......@@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({
1221 text: Schema.String,
1322 diff: Schema.String,
1423 fenceLanguage: Schema.optional(Schema.String),
24 selection: Schema.optional(ReviewCommentSelectionSchema),
1525});
1626
1727export interface ReviewCommentContext {
......@@ -25,6 +35,7 @@ export interface ReviewCommentContext {
2535 readonly text: string;
2636 readonly diff: string;
2737 readonly fenceLanguage?: string | undefined;
38 readonly selection?: ReviewCommentSelection | undefined;
2839}
2940
3041interface DiffReviewLine {
......@@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string {
267278 return value.endsWith("\n") ? value.slice(0, -1) : value;
268279}
269280
270function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffReviewLine> {
281function buildDiffReviewLines(
282 fileDiff: FileDiffMetadata,
283 includeExpandedContext: boolean,
284 slice?: { readonly startIndex: number; readonly endIndex: number },
285): ReadonlyArray<DiffReviewLine> {
271286 const rows: DiffReviewLine[] = [];
287 let rowIndex = 0;
288 let oldContextStart = 1;
289 let newContextStart = 1;
290 const pushRow = (row: DiffReviewLine) => {
291 if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) {
292 rows.push(row);
293 }
294 rowIndex += 1;
295 };
296 const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => {
297 const count = Math.max(0, lineCount);
298 const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0;
299 const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1;
300 for (let offset = firstOffset; offset <= lastOffset; offset += 1) {
301 rows.push({
302 change: "context",
303 oldLineNumber: oldStart + offset,
304 newLineNumber: newStart + offset,
305 content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""),
306 });
307 }
308 rowIndex += count;
309 };
272310
273311 for (const hunk of fileDiff.hunks) {
312 if (includeExpandedContext) {
313 const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);
314 const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);
315 const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart);
316 pushContextGap(oldContextStart, newContextStart, contextLines);
317 }
318
274319 let oldLineNumber = hunk.deletionStart;
275320 let newLineNumber = hunk.additionStart;
276321 let deletionLineIndex = hunk.deletionLineIndex;
......@@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev
279324 for (const segment of hunk.hunkContent) {
280325 if (segment.type === "context") {
281326 for (let index = 0; index < segment.lines; index += 1) {
282 rows.push({
327 pushRow({
283328 change: "context",
284329 oldLineNumber,
285330 newLineNumber,
......@@ -298,7 +343,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev
298343 }
299344
300345 for (let index = 0; index < segment.deletions; index += 1) {
301 rows.push({
346 pushRow({
302347 change: "delete",
303348 oldLineNumber,
304349 newLineNumber: null,
......@@ -309,7 +354,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev
309354 }
310355
311356 for (let index = 0; index < segment.additions; index += 1) {
312 rows.push({
357 pushRow({
313358 change: "add",
314359 oldLineNumber: null,
315360 newLineNumber,
......@@ -319,6 +364,19 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev
319364 additionLineIndex += 1;
320365 }
321366 }
367
368 oldContextStart = hunk.deletionStart + hunk.deletionCount;
369 newContextStart = hunk.additionStart + hunk.additionCount;
370 if (hunk.deletionCount === 0) oldContextStart += 1;
371 if (hunk.additionCount === 0) newContextStart += 1;
372 }
373
374 if (includeExpandedContext) {
375 const trailingLines = Math.min(
376 fileDiff.deletionLines.length - oldContextStart + 1,
377 fileDiff.additionLines.length - newContextStart + 1,
378 );
379 pushContextGap(oldContextStart, newContextStart, trailingLines);
322380 }
323381
324382 return rows;
......@@ -343,9 +401,20 @@ export function restoreDiffReviewCommentRange(
343401 fileDiff: FileDiffMetadata,
344402 comment: ReviewCommentContext,
345403): SelectedLineRange | null {
346 const lines = buildDiffReviewLines(fileDiff);
347 const startLine = lines[comment.startIndex];
348 const endLine = lines[comment.endIndex];
404 if (comment.selection) return comment.selection;
405
406 const includeExpandedContext = !fileDiff.isPartial;
407 const startLine = buildDiffReviewLines(fileDiff, includeExpandedContext, {
408 startIndex: comment.startIndex,
409 endIndex: comment.startIndex,
410 })[0];
411 const endLine =
412 comment.endIndex === comment.startIndex
413 ? startLine
414 : buildDiffReviewLines(fileDiff, includeExpandedContext, {
415 startIndex: comment.endIndex,
416 endIndex: comment.endIndex,
417 })[0];
349418 if (!startLine || !endLine) return null;
350419 const start = getDiffReviewSelectionPoint(startLine);
351420 const end = getDiffReviewSelectionPoint(endLine);
......@@ -359,15 +428,118 @@ export function restoreDiffReviewCommentRange(
359428}
360429
361430function findDiffReviewLineIndex(
362 lines: ReadonlyArray<DiffReviewLine>,
431 fileDiff: FileDiffMetadata,
363432 lineNumber: number,
364433 side: SelectionSide | undefined,
434 includeExpandedContext = !fileDiff.isPartial,
365435): number {
366 const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber";
367 const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber);
368 if (preferredIndex >= 0) return preferredIndex;
369 const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber";
370 return lines.findIndex((line) => line[fallbackKey] === lineNumber);
436 const findOnSide = (selectedSide: "left" | "right") => {
437 let rowIndex = 0;
438 let oldContextStart = 1;
439 let newContextStart = 1;
440 const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => {
441 const count = Math.max(0, lineCount);
442 const selectedStart = selectedSide === "left" ? oldStart : newStart;
443 const offset = lineNumber - selectedStart;
444 return offset >= 0 && offset < count ? rowIndex + offset : -1;
445 };
446
447 for (const hunk of fileDiff.hunks) {
448 if (includeExpandedContext) {
449 const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);
450 const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);
451 const contextLines = Math.min(
452 oldContextEnd - oldContextStart,
453 newContextEnd - newContextStart,
454 );
455 const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines);
456 if (contextIndex >= 0) return contextIndex;
457 rowIndex += Math.max(0, contextLines);
458 }
459
460 let oldLineNumber = hunk.deletionStart;
461 let newLineNumber = hunk.additionStart;
462 for (const segment of hunk.hunkContent) {
463 if (segment.type === "context") {
464 const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines);
465 if (contextIndex >= 0) return contextIndex;
466 rowIndex += segment.lines;
467 oldLineNumber += segment.lines;
468 newLineNumber += segment.lines;
469 continue;
470 }
471
472 if (
473 selectedSide === "left" &&
474 lineNumber >= oldLineNumber &&
475 lineNumber < oldLineNumber + segment.deletions
476 ) {
477 return rowIndex + lineNumber - oldLineNumber;
478 }
479 rowIndex += segment.deletions;
480 oldLineNumber += segment.deletions;
481
482 if (
483 selectedSide === "right" &&
484 lineNumber >= newLineNumber &&
485 lineNumber < newLineNumber + segment.additions
486 ) {
487 return rowIndex + lineNumber - newLineNumber;
488 }
489 rowIndex += segment.additions;
490 newLineNumber += segment.additions;
491 }
492
493 oldContextStart = hunk.deletionStart + hunk.deletionCount;
494 newContextStart = hunk.additionStart + hunk.additionCount;
495 if (hunk.deletionCount === 0) oldContextStart += 1;
496 if (hunk.additionCount === 0) newContextStart += 1;
497 }
498
499 if (!includeExpandedContext) return -1;
500 const trailingLines = Math.min(
501 fileDiff.deletionLines.length - oldContextStart + 1,
502 fileDiff.additionLines.length - newContextStart + 1,
503 );
504 return findContextIndex(oldContextStart, newContextStart, trailingLines);
505 };
506
507 const selectedSide = side === "deletions" ? "left" : "right";
508 const preferredIndex = findOnSide(selectedSide);
509 return preferredIndex >= 0
510 ? preferredIndex
511 : findOnSide(selectedSide === "left" ? "right" : "left");
512}
513
514/** Resolve the host-facing coordinates of a line selected in the diff viewer. */
515export function resolveDiffReviewPosition(
516 fileDiff: FileDiffMetadata,
517 lineNumber: number,
518 side: SelectionSide | undefined,
519): PullRequestReviewPosition | null {
520 const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side);
521 if (lineIndex < 0) return null;
522 const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, {
523 startIndex: lineIndex,
524 endIndex: lineIndex,
525 })[0];
526 if (line === undefined) return null;
527
528 switch (line.change) {
529 case "add":
530 return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber };
531 case "delete":
532 return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber };
533 case "context":
534 return line.oldLineNumber === null || line.newLineNumber === null
535 ? null
536 : {
537 kind: "context",
538 oldLine: line.oldLineNumber,
539 newLine: line.newLineNumber,
540 side: side === "deletions" ? "left" : "right",
541 };
542 }
371543}
372544
373545function getDiffRange(
......@@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: {
416588 range: SelectedLineRange;
417589 text: string;
418590}): ReviewCommentContext | null {
419 const lines = buildDiffReviewLines(input.fileDiff);
420 const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side);
591 const includeExpandedContext = !input.fileDiff.isPartial;
592 const startIndex = findDiffReviewLineIndex(
593 input.fileDiff,
594 input.range.start,
595 input.range.side,
596 includeExpandedContext,
597 );
421598 const endIndex = findDiffReviewLineIndex(
422 lines,
599 input.fileDiff,
423600 input.range.end,
424601 input.range.endSide ?? input.range.side,
602 includeExpandedContext,
425603 );
426604 if (startIndex < 0 || endIndex < 0) return null;
427605
428606 const normalizedStartIndex = Math.min(startIndex, endIndex);
429607 const normalizedEndIndex = Math.max(startIndex, endIndex);
430 const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1);
608 const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, {
609 startIndex: normalizedStartIndex,
610 endIndex: normalizedEndIndex,
611 });
431612 const oldRange = getDiffRange(selectedLines, "oldLineNumber");
432613 const newRange = getDiffRange(selectedLines, "newLineNumber");
433614
......@@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: {
445626 ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`),
446627 ].join("\n"),
447628 fenceLanguage: "diff",
629 selection: {
630 start: input.range.start,
631 side: input.range.side ?? "additions",
632 end: input.range.end,
633 endSide: input.range.endSide ?? input.range.side ?? "additions",
634 },
448635 };
449636}
450637
packages/contracts/src/pullRequest.ts+21-2
......@@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({
856856});
857857export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type;
858858
859/** The coordinates of one line in a pull request diff. */
860export const PullRequestReviewPosition = Schema.Union([
861 Schema.Struct({
862 kind: Schema.Literal("added"),
863 newLine: PositiveInt,
864 }),
865 Schema.Struct({
866 kind: Schema.Literal("deleted"),
867 oldLine: PositiveInt,
868 }),
869 Schema.Struct({
870 kind: Schema.Literal("context"),
871 oldLine: PositiveInt,
872 newLine: PositiveInt,
873 /** Which copy of an unchanged line the reviewer selected in a split diff. */
874 side: PullRequestDiffSide,
875 }),
876]);
877export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type;
878
859879/** One remark in a review that has not been sent yet, anchored to a line of the diff. */
860880export const PullRequestReviewCommentDraft = Schema.Struct({
861881 path: TrimmedNonEmptyString,
......@@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({
865885 * the hosts that address a comment by one path ignore this.
866886 */
867887 oldPath: Schema.optional(TrimmedNonEmptyString),
868 line: PositiveInt,
869 side: PullRequestDiffSide,
888 position: PullRequestReviewPosition,
870889 body: CommentBody,
871890});
872891export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type;