Skip to content

Commit d9ec3c8

Browse files
authored
feat(pr-review): native PR review — diffs, comments, checks, approve & merge in-app (#3174)
1 parent 7235613 commit d9ec3c8

34 files changed

Lines changed: 2042 additions & 25 deletions

packages/core/src/git/router-schemas.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,37 @@ export const updatePrByUrlOutput = z.object({
434434
});
435435
export type UpdatePrByUrlOutput = z.infer<typeof updatePrByUrlOutput>;
436436

437+
export type {
438+
ApprovePrOutput,
439+
GetPrChecksOutput,
440+
GetPrCommentsOutput,
441+
MergePrOutput,
442+
PrCheck,
443+
PrCheckBucket,
444+
PrConversationComment,
445+
PrInfoByUrlOutput,
446+
PrMergeMethod,
447+
} from "@posthog/shared";
448+
// Native PR review schemas (PR overview, approve/merge, CI checks,
449+
// conversation comments) are defined once in `@posthog/shared`'s git domain
450+
// and re-exported here for the host router and UI.
451+
export {
452+
approvePrInput,
453+
approvePrOutput,
454+
getPrChecksInput,
455+
getPrChecksOutput,
456+
getPrCommentsInput,
457+
getPrCommentsOutput,
458+
getPrInfoByUrlInput,
459+
getPrInfoByUrlOutput,
460+
mergePrInput,
461+
mergePrOutput,
462+
prCheckBucketSchema,
463+
prCheckSchema,
464+
prConversationCommentSchema,
465+
prMergeMethodSchema,
466+
} from "@posthog/shared";
467+
437468
export const getBranchChangedFilesInput = z.object({
438469
repo: z.string(),
439470
branch: z.string(),

packages/git/src/gh.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,17 @@ export interface GhExecOptions {
2525
* MCP tool awaiting it — indefinitely. Omit for no timeout.
2626
*/
2727
timeoutMs?: number;
28+
/**
29+
* Max stdout/stderr bytes before the child is killed. Node's execFile
30+
* default is 1 MiB, which paginated `gh api` calls (PR files, comments)
31+
* blow past on busy PRs — the call then dies with "maxBuffer length
32+
* exceeded" instead of returning data.
33+
*/
34+
maxBuffer?: number;
2835
}
2936

37+
const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
38+
3039
export function execGh(
3140
args: string[],
3241
options: GhExecOptions = {},
@@ -37,7 +46,12 @@ export function execGh(
3746
const child = childProcess.execFile(
3847
"gh",
3948
args,
40-
{ cwd: options.cwd, env, timeout: options.timeoutMs ?? 0 },
49+
{
50+
cwd: options.cwd,
51+
env,
52+
timeout: options.timeoutMs ?? 0,
53+
maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
54+
},
4155
(error, stdout, stderr) => {
4256
if (!error) {
4357
resolve({ stdout, stderr, exitCode: 0 });

packages/host-router/src/routers/git.router.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
GIT_WORKSPACE_CLIENT,
1111
} from "@posthog/core/git/identifiers";
1212
import {
13+
approvePrInput,
14+
approvePrOutput,
1315
checkoutBranchInput,
1416
checkoutBranchOutput,
1517
cloneRepositoryInput,
@@ -58,10 +60,16 @@ import {
5860
getLocalBranchChangedFilesOutput,
5961
getPrChangedFilesInput,
6062
getPrChangedFilesOutput,
63+
getPrChecksInput,
64+
getPrChecksOutput,
65+
getPrCommentsInput,
66+
getPrCommentsOutput,
6167
getPrDetailsByUrlInput,
6268
getPrDetailsByUrlOutput,
6369
getPrDiffStatsBatchInput,
6470
getPrDiffStatsBatchOutput,
71+
getPrInfoByUrlInput,
72+
getPrInfoByUrlOutput,
6573
getPrReviewCommentsInput,
6674
getPrReviewCommentsOutput,
6775
getPrTemplateInput,
@@ -72,6 +80,8 @@ import {
7280
ghStatusOutput,
7381
gitStateSnapshotSchema,
7482
gitStatusOutput,
83+
mergePrInput,
84+
mergePrOutput,
7585
openPrInput,
7686
openPrOutput,
7787
prStatusInput,
@@ -529,6 +539,52 @@ export const gitRouter = router({
529539
}),
530540
),
531541

542+
getPrInfoByUrl: publicProcedure
543+
.input(getPrInfoByUrlInput)
544+
.output(getPrInfoByUrlOutput.nullable())
545+
.query(({ ctx, input }) =>
546+
getWorkspaceClient(ctx.container).git.getPrInfoByUrl.query({
547+
prUrl: input.prUrl,
548+
}),
549+
),
550+
551+
getPrChecks: publicProcedure
552+
.input(getPrChecksInput)
553+
.output(getPrChecksOutput)
554+
.query(({ ctx, input }) =>
555+
getWorkspaceClient(ctx.container).git.getPrChecks.query({
556+
prUrl: input.prUrl,
557+
}),
558+
),
559+
560+
getPrComments: publicProcedure
561+
.input(getPrCommentsInput)
562+
.output(getPrCommentsOutput)
563+
.query(({ ctx, input }) =>
564+
getWorkspaceClient(ctx.container).git.getPrComments.query({
565+
prUrl: input.prUrl,
566+
}),
567+
),
568+
569+
approvePr: publicProcedure
570+
.input(approvePrInput)
571+
.output(approvePrOutput)
572+
.mutation(({ ctx, input }) =>
573+
getWorkspaceClient(ctx.container).git.approvePr.mutate({
574+
prUrl: input.prUrl,
575+
}),
576+
),
577+
578+
mergePr: publicProcedure
579+
.input(mergePrInput)
580+
.output(mergePrOutput)
581+
.mutation(({ ctx, input }) =>
582+
getWorkspaceClient(ctx.container).git.mergePr.mutate({
583+
prUrl: input.prUrl,
584+
method: input.method,
585+
}),
586+
),
587+
532588
getPrReviewComments: publicProcedure
533589
.input(getPrReviewCommentsInput)
534590
.output(getPrReviewCommentsOutput)

packages/shared/src/git-domain.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,105 @@ export type GithubPullRequest = GithubRef;
6767
// git-interaction UI (PR status menu actions).
6868
export const prActionTypeSchema = z.enum(["close", "reopen", "ready", "draft"]);
6969
export type PrActionType = z.infer<typeof prActionTypeSchema>;
70+
71+
// Native PR review schemas (PR overview, approve/merge, CI checks,
72+
// conversation comments). Defined once here and re-exported by
73+
// `@posthog/core/git/router-schemas` and workspace-server's git schemas so
74+
// the tRPC layers on both sides of the boundary share one source of truth.
75+
76+
/** Full PR overview (title/body/branches/stats) for the native in-app PR view. */
77+
export const getPrInfoByUrlInput = z.object({ prUrl: z.string() });
78+
79+
export const getPrInfoByUrlOutput = z.object({
80+
number: z.number(),
81+
title: z.string(),
82+
body: z.string(),
83+
author: z.string().nullable(),
84+
state: z.string(),
85+
merged: z.boolean(),
86+
draft: z.boolean(),
87+
/** GitHub computes mergeability asynchronously; null until it settles. */
88+
mergeable: z.boolean().nullable(),
89+
/**
90+
* GitHub's `mergeable_state`: "clean" | "unstable" | "blocked" | "dirty" |
91+
* "behind" | "draft" | "unknown". "blocked" means branch protection forbids
92+
* the merge for this viewer — e.g. a required approving review is missing
93+
* (authors can't approve their own PRs) or required checks are failing.
94+
* Kept as a plain string so an undocumented value can't fail the parse.
95+
*/
96+
mergeStateStatus: z.string().catch("unknown"),
97+
baseRefName: z.string().nullable(),
98+
headRefName: z.string().nullable(),
99+
additions: z.number(),
100+
deletions: z.number(),
101+
changedFiles: z.number(),
102+
});
103+
104+
export type PrInfoByUrlOutput = z.infer<typeof getPrInfoByUrlOutput>;
105+
106+
export const approvePrInput = z.object({ prUrl: z.string() });
107+
108+
export const approvePrOutput = z.object({
109+
success: z.boolean(),
110+
message: z.string(),
111+
});
112+
113+
export type ApprovePrOutput = z.infer<typeof approvePrOutput>;
114+
115+
export const prMergeMethodSchema = z.enum(["merge", "squash", "rebase"]);
116+
export type PrMergeMethod = z.infer<typeof prMergeMethodSchema>;
117+
118+
export const mergePrInput = z.object({
119+
prUrl: z.string(),
120+
method: prMergeMethodSchema,
121+
});
122+
123+
export const mergePrOutput = z.object({
124+
success: z.boolean(),
125+
message: z.string(),
126+
});
127+
128+
export type MergePrOutput = z.infer<typeof mergePrOutput>;
129+
130+
// CI check runs / commit statuses for a PR, via `gh pr checks`.
131+
export const prCheckBucketSchema = z.enum([
132+
"fail",
133+
"cancel",
134+
"pending",
135+
"pass",
136+
"skipping",
137+
]);
138+
export type PrCheckBucket = z.infer<typeof prCheckBucketSchema>;
139+
140+
export const prCheckSchema = z.object({
141+
name: z.string(),
142+
bucket: prCheckBucketSchema,
143+
link: z.string().nullable(),
144+
workflow: z.string().nullable(),
145+
description: z.string().nullable(),
146+
});
147+
export type PrCheck = z.infer<typeof prCheckSchema>;
148+
149+
export const getPrChecksInput = z.object({ prUrl: z.string() });
150+
/** Null means the checks couldn't be fetched; [] means none reported. */
151+
export const getPrChecksOutput = z.array(prCheckSchema).nullable();
152+
export type GetPrChecksOutput = z.infer<typeof getPrChecksOutput>;
153+
154+
// Conversation (issue) comments and review summaries on a PR. Inline review
155+
// comments live in `prReviewThreadSchema` above.
156+
export const prConversationCommentSchema = z.object({
157+
id: z.number(),
158+
author: z.string(),
159+
avatarUrl: z.string().nullable(),
160+
body: z.string(),
161+
createdAt: z.string(),
162+
url: z.string().nullable(),
163+
});
164+
export type PrConversationComment = z.infer<typeof prConversationCommentSchema>;
165+
166+
export const getPrCommentsInput = z.object({ prUrl: z.string() });
167+
/** Null means the comments couldn't be fetched; [] means none. */
168+
export const getPrCommentsOutput = z
169+
.array(prConversationCommentSchema)
170+
.nullable();
171+
export type GetPrCommentsOutput = z.infer<typeof getPrCommentsOutput>;

packages/ui/src/features/code-review/components/PatchedFileDiff.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type FileDiffMetadata, processFile } from "@pierre/diffs";
22
import type { PrCommentThread } from "@posthog/core/code-review/types";
33
import { isBinaryFile } from "@posthog/shared";
44
import type { ChangedFile } from "@posthog/shared/domain-types";
5-
import { useMemo } from "react";
5+
import { type ReactNode, useMemo } from "react";
66
import { DeferredDiffPlaceholder, DiffFileHeader } from "../reviewShellParts";
77
import type { DiffOptions } from "../types";
88
import { InteractiveFileDiff } from "./InteractiveFileDiff";
@@ -17,6 +17,8 @@ interface PatchedFileDiffProps {
1717
externalUrl?: string;
1818
prUrl?: string | null;
1919
commentThreads?: Map<number, PrCommentThread>;
20+
/** Extra controls in the file header row (e.g. a "Viewed" toggle). */
21+
headerTrailing?: ReactNode;
2022
}
2123

2224
export function PatchedFileDiff({
@@ -29,6 +31,7 @@ export function PatchedFileDiff({
2931
externalUrl,
3032
prUrl,
3133
commentThreads,
34+
headerTrailing,
3235
}: PatchedFileDiffProps) {
3336
const fileDiff = useMemo((): FileDiffMetadata | undefined => {
3437
if (!file.patch) return undefined;
@@ -60,6 +63,7 @@ export function PatchedFileDiff({
6063
collapsed={collapsed}
6164
onToggle={onToggle}
6265
externalUrl={externalUrl}
66+
headerTrailing={headerTrailing}
6367
/>
6468
);
6569
}
@@ -74,6 +78,7 @@ export function PatchedFileDiff({
7478
collapsed={collapsed}
7579
onToggle={onToggle}
7680
externalUrl={externalUrl}
81+
headerTrailing={headerTrailing}
7782
/>
7883
);
7984
}
@@ -90,6 +95,7 @@ export function PatchedFileDiff({
9095
fileDiff={fd}
9196
collapsed={collapsed}
9297
onToggle={onToggle}
98+
trailing={headerTrailing}
9399
/>
94100
)}
95101
/>

packages/ui/src/features/code-review/reviewShellParts.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export {
2929

3030
const STICKY_HEADER_CSS = `[data-diffs-header] { position: sticky; top: 0; z-index: 1; background: var(--gray-2); }`;
3131

32-
function useDiffOptions() {
32+
export function useDiffOptions() {
3333
const viewMode = useDiffViewerStore((s) => s.viewMode);
3434
const wordWrap = useDiffViewerStore((s) => s.wordWrap);
3535
const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles);
@@ -209,6 +209,7 @@ export function DiffFileHeader({
209209
onDiscard,
210210
onStage,
211211
staged,
212+
trailing,
212213
}: {
213214
fileDiff: FileDiffMetadata;
214215
collapsed: boolean;
@@ -217,6 +218,8 @@ export function DiffFileHeader({
217218
onDiscard?: () => void;
218219
onStage?: () => void;
219220
staged?: boolean;
221+
/** Extra controls rendered after the action buttons (e.g. a "Viewed" toggle). */
222+
trailing?: ReactNode;
220223
}) {
221224
const fullPath =
222225
fileDiff.prevName && fileDiff.prevName !== fileDiff.name
@@ -234,7 +237,7 @@ export function DiffFileHeader({
234237
collapsed={collapsed}
235238
onToggle={onToggle}
236239
trailing={
237-
(onStage || onDiscard || onOpenFile) && (
240+
(onStage || onDiscard || onOpenFile || trailing) && (
238241
<span className="ml-auto inline-flex items-center gap-[2px]">
239242
{onStage && (
240243
<Tooltip content={staged ? "Unstage" : "Stage"}>
@@ -278,6 +281,7 @@ export function DiffFileHeader({
278281
</button>
279282
</Tooltip>
280283
)}
284+
{trailing}
281285
</span>
282286
)
283287
}
@@ -294,6 +298,7 @@ export function DeferredDiffPlaceholder({
294298
onToggle,
295299
onShow,
296300
externalUrl,
301+
headerTrailing,
297302
}: {
298303
filePath: string;
299304
linesAdded: number;
@@ -303,6 +308,8 @@ export function DeferredDiffPlaceholder({
303308
onToggle: () => void;
304309
onShow?: () => void;
305310
externalUrl?: string;
311+
/** Extra controls in the header row (e.g. a "Viewed" toggle). */
312+
headerTrailing?: ReactNode;
306313
}) {
307314
const { dirPath, fileName } = splitFilePath(filePath);
308315

@@ -315,6 +322,13 @@ export function DeferredDiffPlaceholder({
315322
deletions={linesRemoved}
316323
collapsed={collapsed}
317324
onToggle={onToggle}
325+
trailing={
326+
headerTrailing && (
327+
<span className="ml-auto inline-flex items-center">
328+
{headerTrailing}
329+
</span>
330+
)
331+
}
318332
/>
319333
{!collapsed && (
320334
<div className="w-full border-b border-b-(--gray-5) bg-(--gray-2) p-[16px] text-center text-(--gray-9) text-xs">

0 commit comments

Comments
 (0)