| author | |
| committer | |
| log | d94fbda344398bd266b9f6645e531eb17d3c9e4e |
| tree | 2d4908efb3c0ef22e4365cce1245ecb1a34b5466 |
| parent | b7dbbbaf6c394621cba57cf58dfcc1845f445ef6 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 338 insertions(+), 54 deletions(-)
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts+7-1| ... | ... | @@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => { |
| 776 | 776 | number: 7, |
| 777 | 777 | verdict: "request-changes", |
| 778 | 778 | 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 | ], | |
| 780 | 786 | }); |
| 781 | 787 | |
| 782 | 788 | expect(callAt(0).url).toContain("/pullrequests/7/comments"); |
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+15-1| ... | ... | @@ -12,6 +12,7 @@ import type { |
| 12 | 12 | PullRequestMergeMethod, |
| 13 | 13 | PullRequestMergeability, |
| 14 | 14 | PullRequestReviewCommentDraft, |
| 15 | PullRequestReviewPosition, | |
| 15 | 16 | PullRequestReviewThread, |
| 16 | 17 | PullRequestReviewVerdict, |
| 17 | 18 | PullRequestReviewerCandidateList, |
| ... | ... | @@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { |
| 364 | 365 | } |
| 365 | 366 | } |
| 366 | 367 | |
| 368 | function 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 | ||
| 367 | 381 | export const make = Effect.gen(function* () { |
| 368 | 382 | const bitbucket = yield* BitbucketApi.BitbucketApi; |
| 369 | 383 | |
| ... | ... | @@ -794,7 +808,7 @@ export const make = Effect.gen(function* () { |
| 794 | 808 | content: { raw: comment.body }, |
| 795 | 809 | inline: { |
| 796 | 810 | path: comment.path, |
| 797 | ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), | |
| 811 | ...bitbucketReviewPosition(comment.position), | |
| 798 | 812 | }, |
| 799 | 813 | }), |
| 800 | 814 | }), |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+1-1| ... | ... | @@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1624 | 1624 | number: 7, |
| 1625 | 1625 | verdict: "approve", |
| 1626 | 1626 | 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" }], | |
| 1628 | 1628 | }); |
| 1629 | 1629 | |
| 1630 | 1630 | expect(callAt(0).args).toEqual([ |
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts+7-2| ... | ... | @@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => { |
| 1018 | 1018 | verdict: "approve", |
| 1019 | 1019 | body: "Looks right.", |
| 1020 | 1020 | 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 | }, | |
| 1022 | 1027 | ], |
| 1023 | 1028 | }); |
| 1024 | 1029 | |
| ... | ... | @@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => { |
| 1197 | 1202 | number: 7, |
| 1198 | 1203 | verdict: "comment", |
| 1199 | 1204 | 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" }], | |
| 1201 | 1206 | }), |
| 1202 | 1207 | ); |
| 1203 | 1208 |
apps/server/src/pullRequest/GitLabPullRequestCli.ts+18-3| ... | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | 14 | PullRequestReaction, |
| 15 | 15 | PullRequestReactionContent, |
| 16 | 16 | PullRequestReviewCommentDraft, |
| 17 | PullRequestReviewPosition, | |
| 17 | 18 | PullRequestReviewThread, |
| 18 | 19 | PullRequestReviewVerdict, |
| 19 | 20 | PullRequestReviewerCandidateList, |
| ... | ... | @@ -400,6 +401,22 @@ function projectPath(repository: string): string { |
| 400 | 401 | return encodeURIComponent(repository.trim()); |
| 401 | 402 | } |
| 402 | 403 | |
| 404 | function 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 | ||
| 403 | 420 | function stateParam(state: PullRequestListState): string { |
| 404 | 421 | // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, |
| 405 | 422 | // and it spans every state under `all`. |
| ... | ... | @@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () { |
| 1324 | 1341 | // draft carries the name the file had before the change. |
| 1325 | 1342 | old_path: comment.oldPath ?? comment.path, |
| 1326 | 1343 | new_path: comment.path, |
| 1327 | ...(comment.side === "left" | |
| 1328 | ? { old_line: comment.line } | |
| 1329 | : { new_line: comment.line }), | |
| 1344 | ...gitLabReviewPositionLines(comment.position), | |
| 1330 | 1345 | }, |
| 1331 | 1346 | }), |
| 1332 | 1347 | }), |
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", () => |
| 1534 | 1534 | number: 1, |
| 1535 | 1535 | verdict: "comment", |
| 1536 | 1536 | 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" }], | |
| 1538 | 1538 | }), |
| 1539 | 1539 | ); |
| 1540 | 1540 |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+10-2| ... | ... | @@ -1147,8 +1147,16 @@ describe("review submission payload", () => { |
| 1147 | 1147 | verdict: "request-changes", |
| 1148 | 1148 | body: "Two things.", |
| 1149 | 1149 | 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 | }, | |
| 1152 | 1160 | ], |
| 1153 | 1161 | }), |
| 1154 | 1162 | ) as Record<string, unknown>; |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+18-2| ... | ... | @@ -17,6 +17,7 @@ import type { |
| 17 | 17 | PullRequestReactionContent, |
| 18 | 18 | PullRequestReviewCommentDraft, |
| 19 | 19 | PullRequestReviewDecision, |
| 20 | PullRequestReviewPosition, | |
| 20 | 21 | PullRequestReviewThread, |
| 21 | 22 | PullRequestReviewVerdict, |
| 22 | 23 | PullRequestReviewerCandidate, |
| ... | ... | @@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St |
| 933 | 934 | } |
| 934 | 935 | }`; |
| 935 | 936 | |
| 937 | function 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 | ||
| 936 | 953 | /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ |
| 937 | 954 | export function buildReviewSubmissionJson(input: { |
| 938 | 955 | readonly verdict: PullRequestReviewVerdict; |
| ... | ... | @@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: { |
| 944 | 961 | body: input.body, |
| 945 | 962 | comments: input.comments.map((comment) => ({ |
| 946 | 963 | path: comment.path, |
| 947 | line: comment.line, | |
| 948 | side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), | |
| 964 | ...gitHubReviewPosition(comment.position), | |
| 949 | 965 | body: comment.body, |
| 950 | 966 | })), |
| 951 | 967 | }); |
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+33-12| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | 6 | PullRequestDiffSide, |
| 7 | 7 | PullRequestOmittedFileStat, |
| 8 | 8 | PullRequestRef, |
| 9 | PullRequestReviewPosition, | |
| 9 | 10 | PullRequestReviewThread, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { |
| ... | ... | @@ -43,7 +44,11 @@ import { |
| 43 | 44 | } from "~/lib/diffRendering"; |
| 44 | 45 | import { cn } from "~/lib/utils"; |
| 45 | 46 | import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; |
| 46 | import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; | |
| 47 | import { | |
| 48 | buildDiffReviewComment, | |
| 49 | resolveDiffReviewPosition, | |
| 50 | type ReviewCommentContext, | |
| 51 | } from "~/reviewCommentContext"; | |
| 47 | 52 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 48 | 53 | import { useEnvironmentQuery } from "~/state/query"; |
| 49 | 54 | import { useAtomCommand } from "~/state/use-atom-command"; |
| ... | ... | @@ -129,8 +134,7 @@ interface DraftAnchor { |
| 129 | 134 | readonly path: string; |
| 130 | 135 | /** What the file was called before the change, for the hosts that resolve a position by both. */ |
| 131 | 136 | readonly oldPath: string | null; |
| 132 | readonly line: number; | |
| 133 | readonly side: PullRequestDiffSide; | |
| 137 | readonly position: PullRequestReviewPosition; | |
| 134 | 138 | /** The whole selection, which the comment collapses to one line but a question keeps. */ |
| 135 | 139 | readonly range: SelectedLineRange; |
| 136 | 140 | } |
| ... | ... | @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { |
| 148 | 152 | return side === "left" ? ("deletions" as const) : ("additions" as const); |
| 149 | 153 | } |
| 150 | 154 | |
| 151 | function fromViewerSide(side: string | undefined): PullRequestDiffSide { | |
| 152 | return side === "deletions" ? "left" : "right"; | |
| 155 | function 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 | } | |
| 153 | 170 | } |
| 154 | 171 | |
| 155 | 172 | /** |
| ... | ... | @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ |
| 442 | 459 | if (commit === null) { |
| 443 | 460 | for (const comment of pendingComments) { |
| 444 | 461 | 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); | |
| 446 | 464 | } |
| 447 | 465 | } |
| 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 | } | |
| 449 | 470 | |
| 450 | 471 | const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); |
| 451 | 472 | |
| ... | ... | @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ |
| 594 | 615 | // that silently lost its first line on the other hosts would be worse than one line. |
| 595 | 616 | const path = resolveFileDiffPath(file); |
| 596 | 617 | const previousPath = resolveFileDiffPreviousPath(file); |
| 618 | const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); | |
| 619 | if (position === null) return; | |
| 597 | 620 | setDraft({ |
| 598 | 621 | fileKey: item.id, |
| 599 | 622 | path, |
| 600 | 623 | oldPath: previousPath === path ? null : previousPath, |
| 601 | line: range.end, | |
| 602 | side: fromViewerSide(range.endSide ?? range.side), | |
| 624 | position, | |
| 603 | 625 | range, |
| 604 | 626 | }); |
| 605 | 627 | }, |
| ... | ... | @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ |
| 842 | 864 | {annotation.metadata.draft && draft ? ( |
| 843 | 865 | <DiffCommentAnnotation |
| 844 | 866 | kind="draft" |
| 845 | rangeLabel={`${draft.path}:${draft.line}`} | |
| 867 | rangeLabel={`${draft.path}:${getReviewPositionAnchor(draft.position).line}`} | |
| 846 | 868 | text="" |
| 847 | 869 | submitLabel="Add to review" |
| 848 | 870 | {...(onAskAboutSelection |
| ... | ... | @@ -864,8 +886,7 @@ export function PullRequestCodeTab({ |
| 864 | 886 | id: nextPendingReviewCommentId(), |
| 865 | 887 | path: draft.path, |
| 866 | 888 | ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), |
| 867 | line: draft.line, | |
| 868 | side: draft.side, | |
| 889 | position: draft.position, | |
| 869 | 890 | body, |
| 870 | 891 | }); |
| 871 | 892 | 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"; |
| 3 | 3 | import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore"; |
| 4 | 4 | |
| 5 | 5 | function 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 } }; | |
| 7 | 7 | } |
| 8 | 8 | |
| 9 | 9 | describe("pull request review drafts", () => { |
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+2-9| ... | ... | @@ -6,17 +6,10 @@ |
| 6 | 6 | * hosts that have no pending review of their own. That also means a draft lives only as long |
| 7 | 7 | * as the tab does, which is why this is deliberately not persisted. |
| 8 | 8 | */ |
| 9 | import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; | |
| 9 | import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; | |
| 10 | 10 | import { create } from "zustand"; |
| 11 | 11 | |
| 12 | export 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 | } | |
| 12 | export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; | |
| 20 | 13 | |
| 21 | 14 | /** |
| 22 | 15 | * 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 @@ |
| 1 | 1 | import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; |
| 2 | import type { PullRequestReviewPosition } from "@t3tools/contracts"; | |
| 2 | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | |
| 5 | const ReviewCommentSelectionSchema = Schema.Struct({ | |
| 6 | start: Schema.Number, | |
| 7 | side: Schema.Literals(["additions", "deletions"]), | |
| 8 | end: Schema.Number, | |
| 9 | endSide: Schema.Literals(["additions", "deletions"]), | |
| 10 | }); | |
| 11 | type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; | |
| 12 | ||
| 4 | 13 | export const ReviewCommentContextSchema = Schema.Struct({ |
| 5 | 14 | id: Schema.String, |
| 6 | 15 | sectionId: Schema.String, |
| ... | ... | @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ |
| 12 | 21 | text: Schema.String, |
| 13 | 22 | diff: Schema.String, |
| 14 | 23 | fenceLanguage: Schema.optional(Schema.String), |
| 24 | selection: Schema.optional(ReviewCommentSelectionSchema), | |
| 15 | 25 | }); |
| 16 | 26 | |
| 17 | 27 | export interface ReviewCommentContext { |
| ... | ... | @@ -25,6 +35,7 @@ export interface ReviewCommentContext { |
| 25 | 35 | readonly text: string; |
| 26 | 36 | readonly diff: string; |
| 27 | 37 | readonly fenceLanguage?: string | undefined; |
| 38 | readonly selection?: ReviewCommentSelection | undefined; | |
| 28 | 39 | } |
| 29 | 40 | |
| 30 | 41 | interface DiffReviewLine { |
| ... | ... | @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { |
| 267 | 278 | return value.endsWith("\n") ? value.slice(0, -1) : value; |
| 268 | 279 | } |
| 269 | 280 | |
| 270 | function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffReviewLine> { | |
| 281 | function buildDiffReviewLines( | |
| 282 | fileDiff: FileDiffMetadata, | |
| 283 | includeExpandedContext: boolean, | |
| 284 | slice?: { readonly startIndex: number; readonly endIndex: number }, | |
| 285 | ): ReadonlyArray<DiffReviewLine> { | |
| 271 | 286 | 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 | }; | |
| 272 | 310 | |
| 273 | 311 | 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 | ||
| 274 | 319 | let oldLineNumber = hunk.deletionStart; |
| 275 | 320 | let newLineNumber = hunk.additionStart; |
| 276 | 321 | let deletionLineIndex = hunk.deletionLineIndex; |
| ... | ... | @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev |
| 279 | 324 | for (const segment of hunk.hunkContent) { |
| 280 | 325 | if (segment.type === "context") { |
| 281 | 326 | for (let index = 0; index < segment.lines; index += 1) { |
| 282 | rows.push({ | |
| 327 | pushRow({ | |
| 283 | 328 | change: "context", |
| 284 | 329 | oldLineNumber, |
| 285 | 330 | newLineNumber, |
| ... | ... | @@ -298,7 +343,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev |
| 298 | 343 | } |
| 299 | 344 | |
| 300 | 345 | for (let index = 0; index < segment.deletions; index += 1) { |
| 301 | rows.push({ | |
| 346 | pushRow({ | |
| 302 | 347 | change: "delete", |
| 303 | 348 | oldLineNumber, |
| 304 | 349 | newLineNumber: null, |
| ... | ... | @@ -309,7 +354,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev |
| 309 | 354 | } |
| 310 | 355 | |
| 311 | 356 | for (let index = 0; index < segment.additions; index += 1) { |
| 312 | rows.push({ | |
| 357 | pushRow({ | |
| 313 | 358 | change: "add", |
| 314 | 359 | oldLineNumber: null, |
| 315 | 360 | newLineNumber, |
| ... | ... | @@ -319,6 +364,19 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffRev |
| 319 | 364 | additionLineIndex += 1; |
| 320 | 365 | } |
| 321 | 366 | } |
| 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); | |
| 322 | 380 | } |
| 323 | 381 | |
| 324 | 382 | return rows; |
| ... | ... | @@ -343,9 +401,20 @@ export function restoreDiffReviewCommentRange( |
| 343 | 401 | fileDiff: FileDiffMetadata, |
| 344 | 402 | comment: ReviewCommentContext, |
| 345 | 403 | ): 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]; | |
| 349 | 418 | if (!startLine || !endLine) return null; |
| 350 | 419 | const start = getDiffReviewSelectionPoint(startLine); |
| 351 | 420 | const end = getDiffReviewSelectionPoint(endLine); |
| ... | ... | @@ -359,15 +428,118 @@ export function restoreDiffReviewCommentRange( |
| 359 | 428 | } |
| 360 | 429 | |
| 361 | 430 | function findDiffReviewLineIndex( |
| 362 | lines: ReadonlyArray<DiffReviewLine>, | |
| 431 | fileDiff: FileDiffMetadata, | |
| 363 | 432 | lineNumber: number, |
| 364 | 433 | side: SelectionSide | undefined, |
| 434 | includeExpandedContext = !fileDiff.isPartial, | |
| 365 | 435 | ): 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. */ | |
| 515 | export 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 | } | |
| 371 | 543 | } |
| 372 | 544 | |
| 373 | 545 | function getDiffRange( |
| ... | ... | @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { |
| 416 | 588 | range: SelectedLineRange; |
| 417 | 589 | text: string; |
| 418 | 590 | }): 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 | ); | |
| 421 | 598 | const endIndex = findDiffReviewLineIndex( |
| 422 | lines, | |
| 599 | input.fileDiff, | |
| 423 | 600 | input.range.end, |
| 424 | 601 | input.range.endSide ?? input.range.side, |
| 602 | includeExpandedContext, | |
| 425 | 603 | ); |
| 426 | 604 | if (startIndex < 0 || endIndex < 0) return null; |
| 427 | 605 | |
| 428 | 606 | const normalizedStartIndex = Math.min(startIndex, endIndex); |
| 429 | 607 | 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 | }); | |
| 431 | 612 | const oldRange = getDiffRange(selectedLines, "oldLineNumber"); |
| 432 | 613 | const newRange = getDiffRange(selectedLines, "newLineNumber"); |
| 433 | 614 | |
| ... | ... | @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { |
| 445 | 626 | ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), |
| 446 | 627 | ].join("\n"), |
| 447 | 628 | 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 | }, | |
| 448 | 635 | }; |
| 449 | 636 | } |
| 450 | 637 |
packages/contracts/src/pullRequest.ts+21-2| ... | ... | @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ |
| 856 | 856 | }); |
| 857 | 857 | export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; |
| 858 | 858 | |
| 859 | /** The coordinates of one line in a pull request diff. */ | |
| 860 | export 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 | ]); | |
| 877 | export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; | |
| 878 | ||
| 859 | 879 | /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ |
| 860 | 880 | export const PullRequestReviewCommentDraft = Schema.Struct({ |
| 861 | 881 | path: TrimmedNonEmptyString, |
| ... | ... | @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ |
| 865 | 885 | * the hosts that address a comment by one path ignore this. |
| 866 | 886 | */ |
| 867 | 887 | oldPath: Schema.optional(TrimmedNonEmptyString), |
| 868 | line: PositiveInt, | |
| 869 | side: PullRequestDiffSide, | |
| 888 | position: PullRequestReviewPosition, | |
| 870 | 889 | body: CommentBody, |
| 871 | 890 | }); |
| 872 | 891 | export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; |