diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 5600d23bd..255bdd84a 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -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; + +// updatePrByUrl schemas +export const prActionType = z.enum(["close", "reopen", "ready", "draft"]); +export type PrActionType = z.infer; + +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; + export const getBranchChangedFilesInput = z.object({ repo: z.string(), branch: z.string(), diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index c1d4ab42d..6a79be317 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -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"; @@ -54,11 +54,14 @@ import type { GitStateSnapshot, GitSyncStatus, OpenPrOutput, + PrActionType, + PrDetailsByUrlOutput, PrStatusOutput, PublishOutput, PullOutput, PushOutput, SyncOutput, + UpdatePrByUrlOutput, } from "./schemas"; const fsPromises = fs.promises; @@ -837,10 +840,10 @@ export class GitService extends TypedEventEmitter { } public async getPrChangedFiles(prUrl: string): Promise { - 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([ @@ -907,6 +910,78 @@ export class GitService extends TypedEventEmitter { } } + public async getPrDetailsByUrl( + prUrl: string, + ): Promise { + 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 { + 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, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index a261f4dac..1041f7836 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -42,6 +42,8 @@ import { getLatestCommitOutput, getPrChangedFilesInput, getPrChangedFilesOutput, + getPrDetailsByUrlInput, + getPrDetailsByUrlOutput, getPrTemplateInput, getPrTemplateOutput, ghStatusOutput, @@ -61,6 +63,8 @@ import { stageFilesInput, syncInput, syncOutput, + updatePrByUrlInput, + updatePrByUrlOutput, validateRepoInput, validateRepoOutput, } from "../../services/git/schemas"; @@ -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) diff --git a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx index ce395912d..208e91991 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -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; @@ -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 ( - + {hasDropdown && ( + + + + + + {config.actions.map((action) => ( + execute(action.id)} + > + {action.label} + + ))} + + + )} + ); } diff --git a/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts b/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts new file mode 100644 index 000000000..f5b832cad --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts @@ -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, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts b/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts new file mode 100644 index 000000000..a3aed52a7 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts @@ -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, + }, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx new file mode 100644 index 000000000..923618a54 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx @@ -0,0 +1,81 @@ +import type { PrActionType } from "@main/services/git/schemas"; +import { GitMerge, GitPullRequest } from "@phosphor-icons/react"; + +export interface PrAction { + id: PrActionType; + label: string; +} + +export interface PrVisualConfig { + color: "gray" | "green" | "red" | "purple"; + icon: React.ReactNode; + label: string; + actions: PrAction[]; +} + +export function getPrVisualConfig( + state: string, + merged: boolean, + draft: boolean, +): PrVisualConfig { + if (merged) { + return { + color: "purple", + icon: , + label: "Merged", + actions: [], + }; + } + if (state === "closed") { + return { + color: "red", + icon: , + label: "Closed", + actions: [{ id: "reopen", label: "Reopen PR" }], + }; + } + if (draft) { + return { + color: "gray", + icon: , + label: "Draft", + actions: [ + { id: "ready", label: "Ready for review" }, + { id: "close", label: "Close PR" }, + ], + }; + } + return { + color: "green", + icon: , + label: "Open", + actions: [ + { id: "draft", label: "Convert to draft" }, + { id: "close", label: "Close PR" }, + ], + }; +} + +export function getOptimisticPrState(action: PrActionType) { + switch (action) { + case "close": + return { state: "closed", merged: false, draft: false }; + case "reopen": + return { state: "open", merged: false, draft: false }; + case "ready": + return { state: "open", merged: false, draft: false }; + case "draft": + return { state: "open", merged: false, draft: true }; + } +} + +export const PR_ACTION_LABELS: Record = { + close: "PR closed", + reopen: "PR reopened", + ready: "PR marked as ready for review", + draft: "PR converted to draft", +}; + +export function parsePrNumber(prUrl: string): string | undefined { + return prUrl.match(/\/pull\/(\d+)/)?.[1]; +} diff --git a/packages/git/src/utils.ts b/packages/git/src/utils.ts index 0cc30790a..1947cc62e 100644 --- a/packages/git/src/utils.ts +++ b/packages/git/src/utils.ts @@ -107,6 +107,18 @@ function execFileAsync( }); } +export interface GitHubPr { + owner: string; + repo: string; + number: number; +} + +export function parsePrUrl(prUrl: string): GitHubPr | null { + const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + if (!match) return null; + return { owner: match[1], repo: match[2], number: Number(match[3]) }; +} + export function parseGitHubUrl(url: string): GitHubRepo | null { // Trim whitespace/newlines that git commands may include const trimmedUrl = url.trim();