Skip to content

Commit d3cd62b

Browse files
authored
feat(code): add PR status and actions to cloud tasks (#1525)
## Problem you can't take any actions on your PR from a cloud task <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes as a first step in the direction of "ph code helping you merge PRs", adding some logic to fetch PR details and add simple actions for draft/open/close/etc... notes: - this will be used as the foundation for more PR stuff like comments and CI - much of this will also be re-used for local tasks when we get to that point <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## [cloud-pr-actions.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/2b275b0b-0ace-41f3-a950-f2ac22978d0c.mp4" />](https://app.graphite.com/user-attachments/video/2b275b0b-0ace-41f3-a950-f2ac22978d0c.mp4) ## How did you test this? manually <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent 02b26ae commit d3cd62b

File tree

8 files changed

+350
-12
lines changed

8 files changed

+350
-12
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,31 @@ export const getPrChangedFilesInput = z.object({
325325
});
326326
export const getPrChangedFilesOutput = z.array(changedFileSchema);
327327

328+
// getPrDetailsByUrl schemas
329+
export const getPrDetailsByUrlInput = z.object({
330+
prUrl: z.string(),
331+
});
332+
export const getPrDetailsByUrlOutput = z.object({
333+
state: z.string(),
334+
merged: z.boolean(),
335+
draft: z.boolean(),
336+
});
337+
export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>;
338+
339+
// updatePrByUrl schemas
340+
export const prActionType = z.enum(["close", "reopen", "ready", "draft"]);
341+
export type PrActionType = z.infer<typeof prActionType>;
342+
343+
export const updatePrByUrlInput = z.object({
344+
prUrl: z.string(),
345+
action: prActionType,
346+
});
347+
export const updatePrByUrlOutput = z.object({
348+
success: z.boolean(),
349+
message: z.string(),
350+
});
351+
export type UpdatePrByUrlOutput = z.infer<typeof updatePrByUrlOutput>;
352+
328353
export const getBranchChangedFilesInput = z.object({
329354
repo: z.string(),
330355
branch: z.string(),

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { CommitSaga } from "@posthog/git/sagas/commit";
2828
import { DiscardFileChangesSaga } from "@posthog/git/sagas/discard";
2929
import { PullSaga } from "@posthog/git/sagas/pull";
3030
import { PushSaga } from "@posthog/git/sagas/push";
31-
import { parseGitHubUrl } from "@posthog/git/utils";
31+
import { parseGitHubUrl, parsePrUrl } from "@posthog/git/utils";
3232
import { inject, injectable } from "inversify";
3333
import { MAIN_TOKENS } from "../../di/tokens";
3434
import { logger } from "../../utils/logger";
@@ -55,11 +55,14 @@ import type {
5555
GitStateSnapshot,
5656
GitSyncStatus,
5757
OpenPrOutput,
58+
PrActionType,
59+
PrDetailsByUrlOutput,
5860
PrStatusOutput,
5961
PublishOutput,
6062
PullOutput,
6163
PushOutput,
6264
SyncOutput,
65+
UpdatePrByUrlOutput,
6366
} from "./schemas";
6467

6568
const fsPromises = fs.promises;
@@ -865,10 +868,10 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
865868
}
866869

867870
public async getPrChangedFiles(prUrl: string): Promise<ChangedFile[]> {
868-
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
869-
if (!match) return [];
871+
const pr = parsePrUrl(prUrl);
872+
if (!pr) return [];
870873

871-
const [, owner, repo, number] = match;
874+
const { owner, repo, number } = pr;
872875

873876
try {
874877
const result = await execGh([
@@ -935,6 +938,78 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
935938
}
936939
}
937940

941+
public async getPrDetailsByUrl(
942+
prUrl: string,
943+
): Promise<PrDetailsByUrlOutput | null> {
944+
const pr = parsePrUrl(prUrl);
945+
if (!pr) return null;
946+
947+
try {
948+
const result = await execGh([
949+
"api",
950+
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
951+
"--jq",
952+
"{state,merged,draft}",
953+
]);
954+
955+
if (result.exitCode !== 0) {
956+
log.warn("Failed to fetch PR details", {
957+
prUrl,
958+
error: result.stderr || result.error,
959+
});
960+
return null;
961+
}
962+
963+
const data = JSON.parse(result.stdout) as {
964+
state: string;
965+
merged: boolean;
966+
draft: boolean;
967+
};
968+
969+
return data;
970+
} catch (error) {
971+
log.warn("Failed to fetch PR details", { prUrl, error });
972+
return null;
973+
}
974+
}
975+
976+
public async updatePrByUrl(
977+
prUrl: string,
978+
action: PrActionType,
979+
): Promise<UpdatePrByUrlOutput> {
980+
const pr = parsePrUrl(prUrl);
981+
if (!pr) {
982+
return { success: false, message: "Invalid PR URL" };
983+
}
984+
985+
try {
986+
const args =
987+
action === "draft"
988+
? ["pr", "ready", "--undo", String(pr.number)]
989+
: ["pr", action, String(pr.number)];
990+
991+
const result = await execGh([
992+
...args,
993+
"--repo",
994+
`${pr.owner}/${pr.repo}`,
995+
]);
996+
997+
if (result.exitCode !== 0) {
998+
const errorMsg = result.stderr || result.error || "Unknown error";
999+
log.warn("Failed to update PR", { prUrl, action, error: errorMsg });
1000+
return { success: false, message: errorMsg };
1001+
}
1002+
1003+
return { success: true, message: result.stdout };
1004+
} catch (error) {
1005+
log.warn("Failed to update PR", { prUrl, action, error });
1006+
return {
1007+
success: false,
1008+
message: error instanceof Error ? error.message : "Unknown error",
1009+
};
1010+
}
1011+
}
1012+
9381013
public async getBranchChangedFiles(
9391014
repo: string,
9401015
branch: string,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import {
4242
getLatestCommitOutput,
4343
getPrChangedFilesInput,
4444
getPrChangedFilesOutput,
45+
getPrDetailsByUrlInput,
46+
getPrDetailsByUrlOutput,
4547
getPrTemplateInput,
4648
getPrTemplateOutput,
4749
ghAuthTokenOutput,
@@ -62,6 +64,8 @@ import {
6264
stageFilesInput,
6365
syncInput,
6466
syncOutput,
67+
updatePrByUrlInput,
68+
updatePrByUrlOutput,
6569
validateRepoInput,
6670
validateRepoOutput,
6771
} from "../../services/git/schemas";
@@ -301,6 +305,21 @@ export const gitRouter = router({
301305
.output(getPrChangedFilesOutput)
302306
.query(({ input }) => getService().getPrChangedFiles(input.prUrl)),
303307

308+
getPrDetailsByUrl: publicProcedure
309+
.input(getPrDetailsByUrlInput)
310+
.output(getPrDetailsByUrlOutput)
311+
.query(async ({ input }) => {
312+
const result = await getService().getPrDetailsByUrl(input.prUrl);
313+
return result ?? { state: "unknown", merged: false, draft: false };
314+
}),
315+
316+
updatePrByUrl: publicProcedure
317+
.input(updatePrByUrlInput)
318+
.output(updatePrByUrlOutput)
319+
.mutation(({ input }) =>
320+
getService().updatePrByUrl(input.prUrl, input.action),
321+
),
322+
304323
getBranchChangedFiles: publicProcedure
305324
.input(getBranchChangedFilesInput)
306325
.output(getBranchChangedFilesOutput)
Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { usePrActions } from "@features/git-interaction/hooks/usePrActions";
2+
import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails";
3+
import {
4+
getPrVisualConfig,
5+
parsePrNumber,
6+
} from "@features/git-interaction/utils/prStatus";
17
import { useSessionForTask } from "@features/sessions/hooks/useSession";
2-
import { Eye } from "@phosphor-icons/react";
3-
import { Button, Flex, Text } from "@radix-ui/themes";
8+
import { ChevronDownIcon } from "@radix-ui/react-icons";
9+
import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes";
410

511
interface CloudGitInteractionHeaderProps {
612
taskId: string;
@@ -11,19 +17,71 @@ export function CloudGitInteractionHeader({
1117
}: CloudGitInteractionHeaderProps) {
1218
const session = useSessionForTask(taskId);
1319
const prUrl = (session?.cloudOutput?.pr_url as string) ?? null;
20+
const {
21+
meta: { state, merged, draft },
22+
} = usePrDetails(prUrl);
23+
const { execute, isPending } = usePrActions(prUrl);
1424

15-
if (!prUrl) return null;
25+
if (!prUrl || state === null) return null;
26+
27+
const config = getPrVisualConfig(state, merged, draft);
28+
const prNumber = parsePrNumber(prUrl);
29+
const hasDropdown = config.actions.length > 0;
1630

1731
return (
18-
<div className="no-drag">
19-
<Button size="1" variant="solid" asChild>
32+
<Flex align="center" gap="0" className="no-drag">
33+
<Button
34+
size="1"
35+
variant="soft"
36+
color={config.color}
37+
asChild
38+
style={
39+
hasDropdown
40+
? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
41+
: undefined
42+
}
43+
>
2044
<a href={prUrl} target="_blank" rel="noopener noreferrer">
2145
<Flex align="center" gap="2">
22-
<Eye size={12} weight="bold" />
23-
<Text size="1">View PR</Text>
46+
{isPending ? <Spinner size="1" /> : config.icon}
47+
<Text size="1">
48+
{config.label}
49+
{prNumber && ` #${prNumber}`}
50+
</Text>
2451
</Flex>
2552
</a>
2653
</Button>
27-
</div>
54+
{hasDropdown && (
55+
<DropdownMenu.Root>
56+
<DropdownMenu.Trigger>
57+
<Button
58+
size="1"
59+
variant="soft"
60+
color={config.color}
61+
disabled={isPending}
62+
style={{
63+
borderTopLeftRadius: 0,
64+
borderBottomLeftRadius: 0,
65+
borderLeft: `1px solid var(--${config.color}-6)`,
66+
paddingLeft: "6px",
67+
paddingRight: "6px",
68+
}}
69+
>
70+
<ChevronDownIcon />
71+
</Button>
72+
</DropdownMenu.Trigger>
73+
<DropdownMenu.Content size="1" align="end">
74+
{config.actions.map((action) => (
75+
<DropdownMenu.Item
76+
key={action.id}
77+
onSelect={() => execute(action.id)}
78+
>
79+
<Text size="1">{action.label}</Text>
80+
</DropdownMenu.Item>
81+
))}
82+
</DropdownMenu.Content>
83+
</DropdownMenu.Root>
84+
)}
85+
</Flex>
2886
);
2987
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
getOptimisticPrState,
3+
PR_ACTION_LABELS,
4+
} from "@features/git-interaction/utils/prStatus";
5+
import type { PrActionType } from "@main/services/git/schemas";
6+
import { useTRPC } from "@renderer/trpc";
7+
import { toast } from "@renderer/utils/toast";
8+
import { useMutation, useQueryClient } from "@tanstack/react-query";
9+
10+
export function usePrActions(prUrl: string | null) {
11+
const trpc = useTRPC();
12+
const queryClient = useQueryClient();
13+
14+
const mutation = useMutation(
15+
trpc.git.updatePrByUrl.mutationOptions({
16+
onSuccess: (data, variables) => {
17+
if (data.success) {
18+
toast.success(PR_ACTION_LABELS[variables.action]);
19+
queryClient.setQueryData(
20+
trpc.git.getPrDetailsByUrl.queryKey({ prUrl: variables.prUrl }),
21+
getOptimisticPrState(variables.action),
22+
);
23+
} else {
24+
toast.error("Failed to update PR", { description: data.message });
25+
}
26+
},
27+
onError: (error) => {
28+
toast.error("Failed to update PR", {
29+
description: error instanceof Error ? error.message : "Unknown error",
30+
});
31+
},
32+
}),
33+
);
34+
35+
return {
36+
execute: (action: PrActionType) => {
37+
if (!prUrl) return;
38+
mutation.mutate({ prUrl, action });
39+
},
40+
isPending: mutation.isPending,
41+
};
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useTRPC } from "@renderer/trpc";
2+
import { useQuery } from "@tanstack/react-query";
3+
4+
export function usePrDetails(prUrl: string | null) {
5+
const trpc = useTRPC();
6+
7+
const metaQuery = useQuery(
8+
trpc.git.getPrDetailsByUrl.queryOptions(
9+
{ prUrl: prUrl as string },
10+
{
11+
enabled: !!prUrl,
12+
staleTime: 60_000,
13+
retry: 1,
14+
},
15+
),
16+
);
17+
18+
return {
19+
meta: {
20+
state: metaQuery.data?.state ?? null,
21+
merged: metaQuery.data?.merged ?? false,
22+
draft: metaQuery.data?.draft ?? false,
23+
isLoading: metaQuery.isLoading,
24+
},
25+
};
26+
}

0 commit comments

Comments
 (0)