Skip to content

Commit 613b76f

Browse files
committed
feat(code): show PR comments in review panel
1 parent 0bc210f commit 613b76f

File tree

15 files changed

+1039
-30
lines changed

15 files changed

+1039
-30
lines changed

apps/code/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@
177177
"react-markdown": "^10.1.0",
178178
"react-resizable-panels": "^3.0.6",
179179
"reflect-metadata": "^0.2.2",
180+
"rehype-raw": "^7.0.0",
181+
"rehype-sanitize": "^6.0.0",
180182
"remark-breaks": "^4.0.0",
181183
"remark-gfm": "^4.0.1",
182184
"smol-toml": "^1.6.0",

apps/code/src/main/services/git/schemas.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,48 @@ export const getPrDetailsByUrlOutput = z.object({
328328
});
329329
export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>;
330330

331+
// getPrReviewComments schemas
332+
export const prReviewCommentUserSchema = z.object({
333+
login: z.string(),
334+
avatar_url: z.string(),
335+
});
336+
337+
export const prReviewCommentSchema = z.object({
338+
id: z.number(),
339+
body: z.string(),
340+
path: z.string(),
341+
line: z.number().nullable(),
342+
original_line: z.number().nullable(),
343+
side: z.enum(["LEFT", "RIGHT"]),
344+
start_line: z.number().nullable(),
345+
start_side: z.enum(["LEFT", "RIGHT"]).nullable(),
346+
diff_hunk: z.string(),
347+
in_reply_to_id: z.number().nullable(),
348+
user: prReviewCommentUserSchema,
349+
created_at: z.string(),
350+
updated_at: z.string(),
351+
subject_type: z.enum(["line", "file"]).nullable(),
352+
});
353+
354+
export type PrReviewComment = z.infer<typeof prReviewCommentSchema>;
355+
356+
export const getPrReviewCommentsInput = z.object({
357+
prUrl: z.string(),
358+
});
359+
export const getPrReviewCommentsOutput = z.array(prReviewCommentSchema);
360+
361+
// replyToPrComment schemas
362+
export const replyToPrCommentInput = z.object({
363+
prUrl: z.string(),
364+
commentId: z.number(),
365+
body: z.string(),
366+
});
367+
export const replyToPrCommentOutput = z.object({
368+
success: z.boolean(),
369+
comment: prReviewCommentSchema.nullable(),
370+
});
371+
export type ReplyToPrCommentOutput = z.infer<typeof replyToPrCommentOutput>;
372+
331373
// updatePrByUrl schemas
332374
export const prActionType = z.enum(["close", "reopen", "ready", "draft"]);
333375
export type PrActionType = z.infer<typeof prActionType>;

apps/code/src/main/services/git/service.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ import type {
5656
OpenPrOutput,
5757
PrActionType,
5858
PrDetailsByUrlOutput,
59+
PrReviewComment,
5960
PrStatusOutput,
6061
PublishOutput,
6162
PullOutput,
6263
PushOutput,
64+
ReplyToPrCommentOutput,
6365
SyncOutput,
6466
UpdatePrByUrlOutput,
6567
} from "./schemas";
@@ -982,6 +984,139 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
982984
}
983985
}
984986

987+
public async getPrReviewComments(prUrl: string): Promise<PrReviewComment[]> {
988+
const pr = parsePrUrl(prUrl);
989+
if (!pr) return [];
990+
991+
const { owner, repo, number } = pr;
992+
993+
try {
994+
const result = await execGh([
995+
"api",
996+
`repos/${owner}/${repo}/pulls/${number}/comments`,
997+
"--paginate",
998+
"--slurp",
999+
]);
1000+
1001+
if (result.exitCode !== 0) {
1002+
throw new Error(
1003+
`Failed to fetch PR review comments: ${result.stderr || result.error || "Unknown error"}`,
1004+
);
1005+
}
1006+
1007+
const pages = JSON.parse(result.stdout) as Array<
1008+
Array<{
1009+
id: number;
1010+
body: string;
1011+
path: string;
1012+
line: number | null;
1013+
original_line: number | null;
1014+
side: "LEFT" | "RIGHT";
1015+
start_line: number | null;
1016+
start_side: "LEFT" | "RIGHT" | null;
1017+
diff_hunk: string;
1018+
in_reply_to_id: number | null;
1019+
user: { login: string; avatar_url: string };
1020+
created_at: string;
1021+
updated_at: string;
1022+
subject_type: "line" | "file" | null;
1023+
}>
1024+
>;
1025+
const comments = pages.flat();
1026+
1027+
return comments.map((c) => ({
1028+
id: c.id,
1029+
body: c.body,
1030+
path: c.path,
1031+
line: c.line ?? null,
1032+
original_line: c.original_line ?? null,
1033+
side: c.side,
1034+
start_line: c.start_line ?? null,
1035+
start_side: c.start_side ?? null,
1036+
diff_hunk: c.diff_hunk,
1037+
in_reply_to_id: c.in_reply_to_id ?? null,
1038+
user: { login: c.user.login, avatar_url: c.user.avatar_url },
1039+
created_at: c.created_at,
1040+
updated_at: c.updated_at,
1041+
subject_type: c.subject_type ?? null,
1042+
}));
1043+
} catch (error) {
1044+
log.warn("Failed to fetch PR review comments", { prUrl, error });
1045+
throw error;
1046+
}
1047+
}
1048+
1049+
public async replyToPrComment(
1050+
prUrl: string,
1051+
commentId: number,
1052+
body: string,
1053+
): Promise<ReplyToPrCommentOutput> {
1054+
const pr = parsePrUrl(prUrl);
1055+
if (!pr) {
1056+
return { success: false, comment: null };
1057+
}
1058+
1059+
try {
1060+
const result = await execGh([
1061+
"api",
1062+
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments/${commentId}/replies`,
1063+
"-X",
1064+
"POST",
1065+
"-f",
1066+
`body=${body}`,
1067+
]);
1068+
1069+
if (result.exitCode !== 0) {
1070+
log.warn("Failed to reply to PR comment", {
1071+
prUrl,
1072+
commentId,
1073+
error: result.stderr || result.error,
1074+
});
1075+
return { success: false, comment: null };
1076+
}
1077+
1078+
const data = JSON.parse(result.stdout) as {
1079+
id: number;
1080+
body: string;
1081+
path: string;
1082+
line: number | null;
1083+
original_line: number | null;
1084+
side: "LEFT" | "RIGHT";
1085+
start_line: number | null;
1086+
start_side: "LEFT" | "RIGHT" | null;
1087+
diff_hunk: string;
1088+
in_reply_to_id: number | null;
1089+
user: { login: string; avatar_url: string };
1090+
created_at: string;
1091+
updated_at: string;
1092+
subject_type: "line" | "file" | null;
1093+
};
1094+
1095+
return {
1096+
success: true,
1097+
comment: {
1098+
id: data.id,
1099+
body: data.body,
1100+
path: data.path,
1101+
line: data.line ?? null,
1102+
original_line: data.original_line ?? null,
1103+
side: data.side,
1104+
start_line: data.start_line ?? null,
1105+
start_side: data.start_side ?? null,
1106+
diff_hunk: data.diff_hunk,
1107+
in_reply_to_id: data.in_reply_to_id ?? null,
1108+
user: { login: data.user.login, avatar_url: data.user.avatar_url },
1109+
created_at: data.created_at,
1110+
updated_at: data.updated_at,
1111+
subject_type: data.subject_type ?? null,
1112+
},
1113+
};
1114+
} catch (error) {
1115+
log.warn("Failed to reply to PR comment", { prUrl, commentId, error });
1116+
return { success: false, comment: null };
1117+
}
1118+
}
1119+
9851120
public async getBranchChangedFiles(
9861121
repo: string,
9871122
branch: string,

apps/code/src/main/trpc/routers/git.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
getPrChangedFilesOutput,
4545
getPrDetailsByUrlInput,
4646
getPrDetailsByUrlOutput,
47+
getPrReviewCommentsInput,
48+
getPrReviewCommentsOutput,
4749
getPrTemplateInput,
4850
getPrTemplateOutput,
4951
ghStatusOutput,
@@ -58,6 +60,8 @@ import {
5860
pullOutput,
5961
pushInput,
6062
pushOutput,
63+
replyToPrCommentInput,
64+
replyToPrCommentOutput,
6165
searchGithubIssuesInput,
6266
searchGithubIssuesOutput,
6367
stageFilesInput,
@@ -315,6 +319,18 @@ export const gitRouter = router({
315319
getService().updatePrByUrl(input.prUrl, input.action),
316320
),
317321

322+
getPrReviewComments: publicProcedure
323+
.input(getPrReviewCommentsInput)
324+
.output(getPrReviewCommentsOutput)
325+
.query(({ input }) => getService().getPrReviewComments(input.prUrl)),
326+
327+
replyToPrComment: publicProcedure
328+
.input(replyToPrCommentInput)
329+
.output(replyToPrCommentOutput)
330+
.mutation(({ input }) =>
331+
getService().replyToPrComment(input.prUrl, input.commentId, input.body),
332+
),
333+
318334
getBranchChangedFiles: publicProcedure
319335
.input(getBranchChangedFilesInput)
320336
.output(getBranchChangedFilesOutput)

apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface DiffViewerStoreState {
1111
loadFullFiles: boolean;
1212
wordDiffs: boolean;
1313
hideWhitespaceChanges: boolean;
14+
showReviewComments: boolean;
1415
}
1516

1617
interface DiffViewerStoreActions {
@@ -20,6 +21,7 @@ interface DiffViewerStoreActions {
2021
toggleLoadFullFiles: () => void;
2122
toggleWordDiffs: () => void;
2223
toggleHideWhitespaceChanges: () => void;
24+
toggleShowReviewComments: () => void;
2325
}
2426

2527
type DiffViewerStore = DiffViewerStoreState & DiffViewerStoreActions;
@@ -32,6 +34,7 @@ export const useDiffViewerStore = create<DiffViewerStore>()(
3234
loadFullFiles: false,
3335
wordDiffs: true,
3436
hideWhitespaceChanges: false,
37+
showReviewComments: true,
3538
setViewMode: (mode) =>
3639
set((state) => {
3740
if (state.viewMode === mode) {
@@ -64,6 +67,8 @@ export const useDiffViewerStore = create<DiffViewerStore>()(
6467
toggleWordDiffs: () => set((s) => ({ wordDiffs: !s.wordDiffs })),
6568
toggleHideWhitespaceChanges: () =>
6669
set((s) => ({ hideWhitespaceChanges: !s.hideWhitespaceChanges })),
70+
toggleShowReviewComments: () =>
71+
set((s) => ({ showReviewComments: !s.showReviewComments })),
6772
}),
6873
{
6974
name: "diff-viewer-storage",

apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1+
import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore";
2+
import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails";
13
import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles";
2-
import type { FileDiffMetadata } from "@pierre/diffs";
4+
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
35
import { processFile } from "@pierre/diffs";
46
import { Flex, Spinner, Text } from "@radix-ui/themes";
57
import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore";
68
import type { ChangedFile, Task } from "@shared/types";
79
import { useMemo } from "react";
10+
import { usePrCommentActions } from "../hooks/usePrCommentActions";
811
import { useReviewComment } from "../hooks/useReviewComment";
9-
import type { DiffOptions, OnCommentCallback } from "../types";
12+
import type {
13+
AnnotationMetadata,
14+
DiffOptions,
15+
OnCommentCallback,
16+
} from "../types";
17+
import {
18+
buildPrCommentAnnotations,
19+
getFileCommentThreads,
20+
groupCommentsIntoThreads,
21+
} from "../utils/prCommentAnnotations";
1022
import { InteractiveFileDiff } from "./InteractiveFileDiff";
1123
import {
1224
DeferredDiffPlaceholder,
@@ -24,9 +36,19 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
2436
const isReviewOpen = useReviewNavigationStore(
2537
(s) => (s.reviewModes[taskId] ?? "closed") !== "closed",
2638
);
39+
const showReviewComments = useDiffViewerStore((s) => s.showReviewComments);
2740
const { effectiveBranch, prUrl, isRunActive, remoteFiles, isLoading } =
2841
useCloudChangedFiles(taskId, task, isReviewOpen);
2942
const onComment = useReviewComment(taskId);
43+
const { comments } = usePrDetails(prUrl, {
44+
includeComments: isReviewOpen && showReviewComments,
45+
});
46+
const prCommentActions = usePrCommentActions(taskId, prUrl);
47+
48+
const commentThreads = useMemo(
49+
() => groupCommentsIntoThreads(comments.data),
50+
[comments.data],
51+
);
3052

3153
const allPaths = useMemo(() => remoteFiles.map((f) => f.path), [remoteFiles]);
3254

@@ -108,6 +130,16 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
108130
collapsed={isCollapsed}
109131
onToggle={() => toggleFile(file.path)}
110132
onComment={onComment}
133+
prCommentAnnotations={
134+
showReviewComments
135+
? buildPrCommentAnnotations(
136+
getFileCommentThreads(commentThreads, file.path),
137+
)
138+
: undefined
139+
}
140+
prCommentActions={
141+
showReviewComments ? prCommentActions : undefined
142+
}
111143
/>
112144
</div>
113145
);
@@ -123,13 +155,17 @@ function CloudFileDiff({
123155
collapsed,
124156
onToggle,
125157
onComment,
158+
prCommentAnnotations,
159+
prCommentActions,
126160
}: {
127161
file: ChangedFile;
128162
prUrl: string | null;
129163
options: DiffOptions;
130164
collapsed: boolean;
131165
onToggle: () => void;
132166
onComment: OnCommentCallback;
167+
prCommentAnnotations?: DiffLineAnnotation<AnnotationMetadata>[];
168+
prCommentActions?: ReturnType<typeof usePrCommentActions>;
133169
}) {
134170
const fileDiff = useMemo((): FileDiffMetadata | undefined => {
135171
if (!file.patch) return undefined;
@@ -160,6 +196,8 @@ function CloudFileDiff({
160196
fileDiff={fileDiff}
161197
options={{ ...options, collapsed }}
162198
onComment={onComment}
199+
prCommentAnnotations={prCommentAnnotations}
200+
prCommentActions={prCommentActions}
163201
renderCustomHeader={(fd) => (
164202
<DiffFileHeader
165203
fileDiff={fd}

apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export function DiffSettingsMenu() {
1313
const toggleHideWhitespaceChanges = useDiffViewerStore(
1414
(s) => s.toggleHideWhitespaceChanges,
1515
);
16+
const showReviewComments = useDiffViewerStore((s) => s.showReviewComments);
17+
const toggleShowReviewComments = useDiffViewerStore(
18+
(s) => s.toggleShowReviewComments,
19+
);
1620

1721
return (
1822
<DropdownMenu.Root>
@@ -42,6 +46,14 @@ export function DiffSettingsMenu() {
4246
{hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"}
4347
</Text>
4448
</DropdownMenu.Item>
49+
<DropdownMenu.Separator />
50+
<DropdownMenu.Item onSelect={toggleShowReviewComments}>
51+
<Text size="1">
52+
{showReviewComments
53+
? "Hide review comments"
54+
: "Show review comments"}
55+
</Text>
56+
</DropdownMenu.Item>
4557
</DropdownMenu.Content>
4658
</DropdownMenu.Root>
4759
);

0 commit comments

Comments
 (0)