Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,31 @@ export const getPrChangedFilesInput = z.object({
});
export const getPrChangedFilesOutput = z.array(changedFileSchema);

// getPrDetailsByUrl schemas
export const getPrDetailsByUrlInput = z.object({
prUrl: z.string(),
});
export const getPrDetailsByUrlOutput = z.object({
state: z.string(),
merged: z.boolean(),
draft: z.boolean(),
});
export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>;

// updatePrByUrl schemas
export const prActionType = z.enum(["close", "reopen", "ready", "draft"]);
export type PrActionType = z.infer<typeof prActionType>;

export const updatePrByUrlInput = z.object({
prUrl: z.string(),
action: prActionType,
});
export const updatePrByUrlOutput = z.object({
success: z.boolean(),
message: z.string(),
});
export type UpdatePrByUrlOutput = z.infer<typeof updatePrByUrlOutput>;

export const getBranchChangedFilesInput = z.object({
repo: z.string(),
branch: z.string(),
Expand Down
83 changes: 79 additions & 4 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { CommitSaga } from "@posthog/git/sagas/commit";
import { DiscardFileChangesSaga } from "@posthog/git/sagas/discard";
import { PullSaga } from "@posthog/git/sagas/pull";
import { PushSaga } from "@posthog/git/sagas/push";
import { parseGitHubUrl } from "@posthog/git/utils";
import { parseGitHubUrl, parsePrUrl } from "@posthog/git/utils";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
Expand All @@ -54,11 +54,14 @@ import type {
GitStateSnapshot,
GitSyncStatus,
OpenPrOutput,
PrActionType,
PrDetailsByUrlOutput,
PrStatusOutput,
PublishOutput,
PullOutput,
PushOutput,
SyncOutput,
UpdatePrByUrlOutput,
} from "./schemas";

const fsPromises = fs.promises;
Expand Down Expand Up @@ -837,10 +840,10 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
}

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

const [, owner, repo, number] = match;
const { owner, repo, number } = pr;

try {
const result = await execGh([
Expand Down Expand Up @@ -907,6 +910,78 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
}
}

public async getPrDetailsByUrl(
prUrl: string,
): Promise<PrDetailsByUrlOutput | null> {
const pr = parsePrUrl(prUrl);
if (!pr) return null;

try {
const result = await execGh([
"api",
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
"--jq",
"{state,merged,draft}",
]);

if (result.exitCode !== 0) {
log.warn("Failed to fetch PR details", {
prUrl,
error: result.stderr || result.error,
});
return null;
}

const data = JSON.parse(result.stdout) as {
state: string;
merged: boolean;
draft: boolean;
};

return data;
} catch (error) {
log.warn("Failed to fetch PR details", { prUrl, error });
return null;
}
}

public async updatePrByUrl(
prUrl: string,
action: PrActionType,
): Promise<UpdatePrByUrlOutput> {
const pr = parsePrUrl(prUrl);
if (!pr) {
return { success: false, message: "Invalid PR URL" };
}

try {
const args =
action === "draft"
? ["pr", "ready", "--undo", String(pr.number)]
: ["pr", action, String(pr.number)];

const result = await execGh([
...args,
"--repo",
`${pr.owner}/${pr.repo}`,
]);

if (result.exitCode !== 0) {
const errorMsg = result.stderr || result.error || "Unknown error";
log.warn("Failed to update PR", { prUrl, action, error: errorMsg });
return { success: false, message: errorMsg };
}

return { success: true, message: result.stdout };
} catch (error) {
log.warn("Failed to update PR", { prUrl, action, error });
return {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
};
}
}

public async getBranchChangedFiles(
repo: string,
branch: string,
Expand Down
19 changes: 19 additions & 0 deletions apps/code/src/main/trpc/routers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
getLatestCommitOutput,
getPrChangedFilesInput,
getPrChangedFilesOutput,
getPrDetailsByUrlInput,
getPrDetailsByUrlOutput,
getPrTemplateInput,
getPrTemplateOutput,
ghStatusOutput,
Expand All @@ -61,6 +63,8 @@ import {
stageFilesInput,
syncInput,
syncOutput,
updatePrByUrlInput,
updatePrByUrlOutput,
validateRepoInput,
validateRepoOutput,
} from "../../services/git/schemas";
Expand Down Expand Up @@ -296,6 +300,21 @@ export const gitRouter = router({
.output(getPrChangedFilesOutput)
.query(({ input }) => getService().getPrChangedFiles(input.prUrl)),

getPrDetailsByUrl: publicProcedure
.input(getPrDetailsByUrlInput)
.output(getPrDetailsByUrlOutput)
.query(async ({ input }) => {
const result = await getService().getPrDetailsByUrl(input.prUrl);
return result ?? { state: "unknown", merged: false, draft: false };
}),

updatePrByUrl: publicProcedure
.input(updatePrByUrlInput)
.output(updatePrByUrlOutput)
.mutation(({ input }) =>
getService().updatePrByUrl(input.prUrl, input.action),
),

getBranchChangedFiles: publicProcedure
.input(getBranchChangedFilesInput)
.output(getBranchChangedFilesOutput)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { usePrActions } from "@features/git-interaction/hooks/usePrActions";
import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails";
import {
getPrVisualConfig,
parsePrNumber,
} from "@features/git-interaction/utils/prStatus";
import { useSessionForTask } from "@features/sessions/hooks/useSession";
import { Eye } from "@phosphor-icons/react";
import { Button, Flex, Text } from "@radix-ui/themes";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes";

interface CloudGitInteractionHeaderProps {
taskId: string;
Expand All @@ -11,19 +17,71 @@ export function CloudGitInteractionHeader({
}: CloudGitInteractionHeaderProps) {
const session = useSessionForTask(taskId);
const prUrl = (session?.cloudOutput?.pr_url as string) ?? null;
const {
meta: { state, merged, draft },
} = usePrDetails(prUrl);
const { execute, isPending } = usePrActions(prUrl);

if (!prUrl) return null;
if (!prUrl || state === null) return null;

const config = getPrVisualConfig(state, merged, draft);
const prNumber = parsePrNumber(prUrl);
const hasDropdown = config.actions.length > 0;

return (
<div className="no-drag">
<Button size="1" variant="solid" asChild>
<Flex align="center" gap="0" className="no-drag">
<Button
size="1"
variant="soft"
color={config.color}
asChild
style={
hasDropdown
? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
: undefined
}
>
<a href={prUrl} target="_blank" rel="noopener noreferrer">
<Flex align="center" gap="2">
<Eye size={12} weight="bold" />
<Text size="1">View PR</Text>
{isPending ? <Spinner size="1" /> : config.icon}
<Text size="1">
{config.label}
{prNumber && ` #${prNumber}`}
</Text>
</Flex>
</a>
</Button>
</div>
{hasDropdown && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="1"
variant="soft"
color={config.color}
disabled={isPending}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: `1px solid var(--${config.color}-6)`,
paddingLeft: "6px",
paddingRight: "6px",
}}
>
<ChevronDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content size="1" align="end">
{config.actions.map((action) => (
<DropdownMenu.Item
key={action.id}
onSelect={() => execute(action.id)}
>
<Text size="1">{action.label}</Text>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
getOptimisticPrState,
PR_ACTION_LABELS,
} from "@features/git-interaction/utils/prStatus";
import type { PrActionType } from "@main/services/git/schemas";
import { useTRPC } from "@renderer/trpc";
import { toast } from "@renderer/utils/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function usePrActions(prUrl: string | null) {
const trpc = useTRPC();
const queryClient = useQueryClient();

const mutation = useMutation(
trpc.git.updatePrByUrl.mutationOptions({
onSuccess: (data, variables) => {
if (data.success) {
toast.success(PR_ACTION_LABELS[variables.action]);
queryClient.setQueryData(
trpc.git.getPrDetailsByUrl.queryKey({ prUrl: variables.prUrl }),
getOptimisticPrState(variables.action),
);
} else {
toast.error("Failed to update PR", { description: data.message });
}
},
onError: (error) => {
toast.error("Failed to update PR", {
description: error instanceof Error ? error.message : "Unknown error",
});
},
}),
);

return {
execute: (action: PrActionType) => {
if (!prUrl) return;
mutation.mutate({ prUrl, action });
},
isPending: mutation.isPending,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useTRPC } from "@renderer/trpc";
import { useQuery } from "@tanstack/react-query";

export function usePrDetails(prUrl: string | null) {
const trpc = useTRPC();

const metaQuery = useQuery(
trpc.git.getPrDetailsByUrl.queryOptions(
{ prUrl: prUrl as string },
{
enabled: !!prUrl,
staleTime: 60_000,
retry: 1,
},
),
);

return {
meta: {
state: metaQuery.data?.state ?? null,
merged: metaQuery.data?.merged ?? false,
draft: metaQuery.data?.draft ?? false,
isLoading: metaQuery.isLoading,
},
};
}
Loading
Loading