Skip to content

Commit 9519a60

Browse files
committed
feat(code): show PR comments in review panel
1 parent 6e56987 commit 9519a60

16 files changed

Lines changed: 996 additions & 82 deletions

File tree

apps/code/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@
186186
"react-markdown": "^10.1.0",
187187
"react-resizable-panels": "^3.0.6",
188188
"reflect-metadata": "^0.2.2",
189+
"rehype-raw": "^7.0.0",
190+
"rehype-sanitize": "^6.0.0",
189191
"remark-breaks": "^4.0.0",
190192
"remark-gfm": "^4.0.1",
191193
"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: 67 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,71 @@ 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 PrReviewComment[][];
1008+
return pages.flat();
1009+
} catch (error) {
1010+
log.warn("Failed to fetch PR review comments", { prUrl, error });
1011+
throw error;
1012+
}
1013+
}
1014+
1015+
public async replyToPrComment(
1016+
prUrl: string,
1017+
commentId: number,
1018+
body: string,
1019+
): Promise<ReplyToPrCommentOutput> {
1020+
const pr = parsePrUrl(prUrl);
1021+
if (!pr) {
1022+
return { success: false, comment: null };
1023+
}
1024+
1025+
try {
1026+
const result = await execGh([
1027+
"api",
1028+
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments/${commentId}/replies`,
1029+
"-X",
1030+
"POST",
1031+
"-f",
1032+
`body=${body}`,
1033+
]);
1034+
1035+
if (result.exitCode !== 0) {
1036+
log.warn("Failed to reply to PR comment", {
1037+
prUrl,
1038+
commentId,
1039+
error: result.stderr || result.error,
1040+
});
1041+
return { success: false, comment: null };
1042+
}
1043+
1044+
const data = JSON.parse(result.stdout) as PrReviewComment;
1045+
return { success: true, comment: data };
1046+
} catch (error) {
1047+
log.warn("Failed to reply to PR comment", { prUrl, commentId, error });
1048+
return { success: false, comment: null };
1049+
}
1050+
}
1051+
9851052
public async getBranchChangedFiles(
9861053
repo: string,
9871054
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: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
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";
24
import type { FileDiffMetadata } from "@pierre/diffs";
35
import { processFile } from "@pierre/diffs";
@@ -6,6 +8,7 @@ import { useReviewNavigationStore } from "@renderer/features/code-review/stores/
68
import type { ChangedFile, Task } from "@shared/types";
79
import { useMemo } from "react";
810
import type { DiffOptions } from "../types";
11+
import type { PrCommentThread } from "../utils/prCommentAnnotations";
912
import { InteractiveFileDiff } from "./InteractiveFileDiff";
1013
import {
1114
DeferredDiffPlaceholder,
@@ -23,8 +26,12 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
2326
const isReviewOpen = useReviewNavigationStore(
2427
(s) => (s.reviewModes[taskId] ?? "closed") !== "closed",
2528
);
29+
const showReviewComments = useDiffViewerStore((s) => s.showReviewComments);
2630
const { effectiveBranch, prUrl, isRunActive, remoteFiles, isLoading } =
2731
useCloudChangedFiles(taskId, task, isReviewOpen);
32+
const { commentThreads } = usePrDetails(prUrl, {
33+
includeComments: isReviewOpen && showReviewComments,
34+
});
2835

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

@@ -44,23 +51,20 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
4451
if (!prUrl && !effectiveBranch && remoteFiles.length === 0) {
4552
if (isRunActive) {
4653
return (
47-
<Flex align="center" justify="center" height="100%">
48-
<Flex align="center" gap="2">
49-
<Spinner size="1" />
50-
<Text size="2" color="gray">
51-
Waiting for changes...
52-
</Text>
54+
<Flex
55+
align="center"
56+
justify="center"
57+
height="100%"
58+
className="text-gray-10"
59+
>
60+
<Flex direction="column" align="center" gap="2">
61+
<Spinner size="2" />
62+
<Text size="2">Waiting for changes...</Text>
5363
</Flex>
5464
</Flex>
5565
);
5666
}
57-
return (
58-
<Flex align="center" justify="center" height="100%">
59-
<Text size="2" color="gray">
60-
No file changes yet
61-
</Text>
62-
</Flex>
63-
);
67+
return null;
6468
}
6569

6670
return (
@@ -105,6 +109,7 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) {
105109
options={diffOptions}
106110
collapsed={isCollapsed}
107111
onToggle={() => toggleFile(file.path)}
112+
commentThreads={showReviewComments ? commentThreads : undefined}
108113
/>
109114
</div>
110115
);
@@ -120,13 +125,15 @@ function CloudFileDiff({
120125
options,
121126
collapsed,
122127
onToggle,
128+
commentThreads,
123129
}: {
124130
file: ChangedFile;
125131
taskId: string;
126132
prUrl: string | null;
127133
options: DiffOptions;
128134
collapsed: boolean;
129135
onToggle: () => void;
136+
commentThreads?: Map<number, PrCommentThread>;
130137
}) {
131138
const fileDiff = useMemo((): FileDiffMetadata | undefined => {
132139
if (!file.patch) return undefined;
@@ -157,6 +164,8 @@ function CloudFileDiff({
157164
fileDiff={fileDiff}
158165
options={{ ...options, collapsed }}
159166
taskId={taskId}
167+
prUrl={prUrl}
168+
commentThreads={commentThreads}
160169
renderCustomHeader={(fd) => (
161170
<DiffFileHeader
162171
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)