Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7806f6f

Browse files
authored
feat(code-review): add custom PR comment chat (#3432)
1 parent 5a8351f commit 7806f6f

6 files changed

Lines changed: 292 additions & 61 deletions

File tree

packages/core/src/code-review/reviewPrompts.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import {
44
buildAskAboutPrCommentPrompt,
55
buildBatchedInlineCommentsPrompt,
6+
buildChatAboutPrCommentPrompt,
67
buildFixPrCommentPrompt,
78
buildInlineCommentPrompt,
89
} from "./reviewPrompts";
@@ -65,7 +66,7 @@ describe("buildBatchedInlineCommentsPrompt", () => {
6566
});
6667
});
6768

68-
describe("buildFixPrCommentPrompt / buildAskAboutPrCommentPrompt", () => {
69+
describe("PR comment prompts", () => {
6970
it("includes the thread body and side", () => {
7071
const out = buildFixPrCommentPrompt("a.ts", 4, "new", [
7172
makeComment("please rename"),
@@ -81,4 +82,19 @@ describe("buildFixPrCommentPrompt / buildAskAboutPrCommentPrompt", () => {
8182
]);
8283
expect(out).toContain("Do not make any changes");
8384
});
85+
86+
it("chat prompt includes the thread and custom message", () => {
87+
const out = buildChatAboutPrCommentPrompt(
88+
'src/a".ts',
89+
8,
90+
"new",
91+
[makeComment("consider extracting this", "reviewer")],
92+
"Is there already a helper for this?",
93+
);
94+
expect(out).toContain('<file path="src/a&quot;.ts" />');
95+
expect(out).toContain("line 8 (new)");
96+
expect(out).toContain("@reviewer");
97+
expect(out).toContain("consider extracting this");
98+
expect(out.endsWith("Is there already a helper for this?")).toBe(true);
99+
});
84100
});

packages/core/src/code-review/reviewPrompts.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ function formatThreadForPrompt(comments: PrReviewComment[]): string {
1515
return comments.map((c) => `@${c.user.login}:\n> ${c.body}`).join("\n\n");
1616
}
1717

18+
function formatPrCommentPromptContext(
19+
filePath: string,
20+
line: number,
21+
side: "old" | "new",
22+
comments: PrReviewComment[],
23+
): string {
24+
const escapedPath = escapeXmlAttr(filePath);
25+
const thread = formatThreadForPrompt(comments);
26+
return `<file path="${escapedPath}" />, line ${line} (${side}):\n\n${thread}`;
27+
}
28+
1829
function formatLineRef(startLine: number, endLine: number): string {
1930
return startLine === endLine
2031
? `line ${startLine}`
@@ -85,9 +96,8 @@ export function buildFixPrCommentPrompt(
8596
side: "old" | "new",
8697
comments: PrReviewComment[],
8798
): string {
88-
const escapedPath = escapeXmlAttr(filePath);
89-
const thread = formatThreadForPrompt(comments);
90-
return `Fix this PR review comment on <file path="${escapedPath}" />, line ${line} (${side}):\n\n${thread}`;
99+
const context = formatPrCommentPromptContext(filePath, line, side, comments);
100+
return `Fix this PR review comment on ${context}`;
91101
}
92102

93103
export function buildAskAboutPrCommentPrompt(
@@ -96,7 +106,17 @@ export function buildAskAboutPrCommentPrompt(
96106
side: "old" | "new",
97107
comments: PrReviewComment[],
98108
): string {
99-
const escapedPath = escapeXmlAttr(filePath);
100-
const thread = formatThreadForPrompt(comments);
101-
return `Help me understand this PR review comment on <file path="${escapedPath}" />, line ${line} (${side}):\n\n${thread}\n\nWhat is this comment asking for and how should I address it? Do not make any changes, your job is simply to chat with me about this comment. If I need further changes, I'll ask.`;
109+
const context = formatPrCommentPromptContext(filePath, line, side, comments);
110+
return `Help me understand this PR review comment on ${context}\n\nWhat is this comment asking for and how should I address it? Do not make any changes, your job is simply to chat with me about this comment. If I need further changes, I'll ask.`;
111+
}
112+
113+
export function buildChatAboutPrCommentPrompt(
114+
filePath: string,
115+
line: number,
116+
side: "old" | "new",
117+
comments: PrReviewComment[],
118+
message: string,
119+
): string {
120+
const context = formatPrCommentPromptContext(filePath, line, side, comments);
121+
return `Regarding this PR review comment on ${context}\n\n${message}`;
102122
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { PrReviewComment } from "@posthog/shared";
2+
import { Theme } from "@radix-ui/themes";
3+
import { render, screen, waitFor } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import type { PrCommentMetadata } from "../types";
7+
import { PrCommentThread } from "./PrCommentThread";
8+
9+
const { reply, resolve, sendPromptToAgent } = vi.hoisted(() => ({
10+
reply: vi.fn(),
11+
resolve: vi.fn(),
12+
sendPromptToAgent: vi.fn(),
13+
}));
14+
15+
vi.mock("../hooks/usePrCommentActions", () => ({
16+
usePrCommentActions: () => ({ reply, resolve }),
17+
}));
18+
19+
vi.mock("../../sessions/sendPromptToAgent", () => ({ sendPromptToAgent }));
20+
21+
function makeComment(): PrReviewComment {
22+
return {
23+
id: 42,
24+
body: "Could this use the shared helper?",
25+
created_at: "2026-07-14T12:00:00Z",
26+
user: {
27+
login: "reviewer",
28+
avatar_url: "",
29+
},
30+
} as PrReviewComment;
31+
}
32+
33+
function makeMetadata(): PrCommentMetadata {
34+
return {
35+
kind: "pr-comment",
36+
threadId: 42,
37+
nodeId: "thread-node",
38+
isResolved: false,
39+
comments: [makeComment()],
40+
isOutdated: false,
41+
isFileLevel: false,
42+
startLine: 8,
43+
endLine: 8,
44+
side: "additions",
45+
};
46+
}
47+
48+
function renderThread() {
49+
return render(
50+
<Theme>
51+
<PrCommentThread
52+
taskId="task-1"
53+
prUrl="https://github.com/PostHog/posthog/pull/1"
54+
filePath="src/example.ts"
55+
metadata={makeMetadata()}
56+
/>
57+
</Theme>,
58+
);
59+
}
60+
61+
describe("PrCommentThread", () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks();
64+
reply.mockResolvedValue(true);
65+
resolve.mockResolvedValue(true);
66+
sendPromptToAgent.mockResolvedValue(true);
67+
});
68+
69+
it("sends a custom chat message with the review context", async () => {
70+
const user = userEvent.setup();
71+
renderThread();
72+
73+
await user.click(screen.getByRole("button", { name: "Chat" }));
74+
await user.type(
75+
screen.getByPlaceholderText("Ask the agent about this comment..."),
76+
"Check whether this helper already exists",
77+
);
78+
await user.click(screen.getByRole("button", { name: "Send" }));
79+
80+
expect(sendPromptToAgent).toHaveBeenCalledWith(
81+
"task-1",
82+
expect.stringContaining("Could this use the shared helper?"),
83+
);
84+
expect(sendPromptToAgent).toHaveBeenCalledWith(
85+
"task-1",
86+
expect.stringContaining("Check whether this helper already exists"),
87+
);
88+
await waitFor(() =>
89+
expect(
90+
screen.queryByPlaceholderText("Ask the agent about this comment..."),
91+
).not.toBeInTheDocument(),
92+
);
93+
});
94+
95+
it("keeps the custom message available when sending fails", async () => {
96+
sendPromptToAgent.mockResolvedValue(false);
97+
const user = userEvent.setup();
98+
renderThread();
99+
100+
await user.click(screen.getByRole("button", { name: "Chat" }));
101+
const textarea = screen.getByPlaceholderText(
102+
"Ask the agent about this comment...",
103+
);
104+
await user.type(textarea, "Keep this draft");
105+
await user.click(screen.getByRole("button", { name: "Send" }));
106+
107+
await waitFor(() =>
108+
expect(
109+
screen.getByPlaceholderText("Ask the agent about this comment..."),
110+
).toHaveValue("Keep this draft"),
111+
);
112+
});
113+
114+
it("keeps a reply composer opened while a chat message is sending", async () => {
115+
let resolveSend: ((success: boolean) => void) | undefined;
116+
sendPromptToAgent.mockReturnValue(
117+
new Promise<boolean>((resolve) => {
118+
resolveSend = resolve;
119+
}),
120+
);
121+
const user = userEvent.setup();
122+
renderThread();
123+
124+
await user.click(screen.getByRole("button", { name: "Chat" }));
125+
await user.type(
126+
screen.getByPlaceholderText("Ask the agent about this comment..."),
127+
"Check this",
128+
);
129+
await user.click(screen.getByRole("button", { name: "Send" }));
130+
await user.click(screen.getByRole("button", { name: "Close composer" }));
131+
await user.click(screen.getByRole("button", { name: "Reply" }));
132+
await user.type(screen.getByPlaceholderText("Write a reply..."), "Keep me");
133+
134+
resolveSend?.(true);
135+
136+
await waitFor(() =>
137+
expect(screen.getByPlaceholderText("Write a reply...")).toHaveValue(
138+
"Keep me",
139+
),
140+
);
141+
});
142+
143+
it("still posts replies without sending them to chat", async () => {
144+
const user = userEvent.setup();
145+
renderThread();
146+
147+
await user.click(screen.getByRole("button", { name: "Reply" }));
148+
await user.type(screen.getByPlaceholderText("Write a reply..."), "Done");
149+
await user.click(screen.getByRole("button", { name: "Reply" }));
150+
151+
expect(reply).toHaveBeenCalledWith(42, "Done");
152+
expect(sendPromptToAgent).not.toHaveBeenCalled();
153+
});
154+
});

0 commit comments

Comments
 (0)