From 87bd9420a827bc56da073d572ce6d5ea343ddd4c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 31 Mar 2026 10:56:50 -0700 Subject: [PATCH 1/3] feat(code): unified PR creation workflow --- .../src/main/services/git/create-pr-saga.ts | 205 +++++++++++ apps/code/src/main/services/git/schemas.ts | 34 +- apps/code/src/main/services/git/service.ts | 101 ++++- apps/code/src/main/trpc/routers/git.ts | 19 +- .../components/CreatePrDialog.stories.tsx | 347 ++++++++++++++++++ .../components/CreatePrDialog.tsx | 322 ++++++++++++++++ .../GitInteractionDialogs.stories.tsx | 134 +------ .../components/GitInteractionDialogs.tsx | 159 ++------ .../components/GitInteractionHeader.tsx | 24 +- .../hooks/useGitInteraction.ts | 262 +++++++------ .../state/gitInteractionLogic.test.ts | 56 ++- .../state/gitInteractionLogic.ts | 88 +++-- .../state/gitInteractionStore.ts | 78 ++-- .../features/git-interaction/types.ts | 11 +- 14 files changed, 1328 insertions(+), 512 deletions(-) create mode 100644 apps/code/src/main/services/git/create-pr-saga.ts create mode 100644 apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx create mode 100644 apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx diff --git a/apps/code/src/main/services/git/create-pr-saga.ts b/apps/code/src/main/services/git/create-pr-saga.ts new file mode 100644 index 000000000..c2808d557 --- /dev/null +++ b/apps/code/src/main/services/git/create-pr-saga.ts @@ -0,0 +1,205 @@ +import { getGitOperationManager } from "@posthog/git/operation-manager"; +import { getHeadSha } from "@posthog/git/queries"; +import { Saga, type SagaLogger } from "@posthog/shared"; +import type { LlmCredentials } from "../llm-gateway/schemas"; +import type { + ChangedFile, + CommitOutput, + CreatePrProgressPayload, + GitSyncStatus, + PublishOutput, + PushOutput, +} from "./schemas"; + +export interface CreatePrSagaInput { + directoryPath: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + credentials?: LlmCredentials; +} + +export interface CreatePrSagaOutput { + prUrl: string | null; +} + +export interface CreatePrDeps { + getCurrentBranch(dir: string): Promise; + createBranch(dir: string, name: string): Promise; + checkoutBranch( + dir: string, + name: string, + ): Promise<{ previousBranch: string; currentBranch: string }>; + getChangedFilesHead(dir: string): Promise; + generateCommitMessage( + dir: string, + credentials: LlmCredentials, + ): Promise<{ message: string }>; + commit(dir: string, message: string): Promise; + getSyncStatus(dir: string): Promise; + push(dir: string): Promise; + publish(dir: string): Promise; + generatePrTitleAndBody( + dir: string, + credentials: LlmCredentials, + ): Promise<{ title: string; body: string }>; + createPr( + dir: string, + title?: string, + body?: string, + draft?: boolean, + ): Promise<{ success: boolean; message: string; prUrl: string | null }>; + onProgress( + step: CreatePrProgressPayload["step"], + message: string, + prUrl?: string, + ): void; +} + +export class CreatePrSaga extends Saga { + readonly sagaName = "CreatePrSaga"; + private deps: CreatePrDeps; + + constructor(deps: CreatePrDeps, logger?: SagaLogger) { + super(logger); + this.deps = deps; + } + + protected async execute( + input: CreatePrSagaInput, + ): Promise { + const { directoryPath, draft, credentials } = input; + let { commitMessage, prTitle, prBody } = input; + + if (input.branchName) { + this.deps.onProgress( + "creating-branch", + `Creating branch ${input.branchName}...`, + ); + + const originalBranch = await this.readOnlyStep( + "get-original-branch", + () => this.deps.getCurrentBranch(directoryPath), + ); + + await this.step({ + name: "creating-branch", + execute: () => this.deps.createBranch(directoryPath, input.branchName!), + rollback: async () => { + if (originalBranch) { + await this.deps.checkoutBranch(directoryPath, originalBranch); + } + }, + }); + } + + const changedFiles = await this.readOnlyStep("check-changes", () => + this.deps.getChangedFilesHead(directoryPath), + ); + + if (changedFiles.length > 0) { + if (!commitMessage && credentials) { + this.deps.onProgress("committing", "Generating commit message..."); + const generated = await this.readOnlyStep( + "generate-commit-message", + async () => { + try { + return await this.deps.generateCommitMessage( + directoryPath, + credentials, + ); + } catch { + return null; + } + }, + ); + if (generated) commitMessage = generated.message; + } + + if (!commitMessage) { + throw new Error("Commit message is required."); + } + + this.deps.onProgress("committing", "Committing changes..."); + + const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () => + getHeadSha(directoryPath), + ); + + await this.step({ + name: "committing", + execute: async () => { + const result = await this.deps.commit(directoryPath, commitMessage!); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => { + const manager = getGitOperationManager(); + await manager.executeWrite(directoryPath, (git) => + git.reset(["--soft", preCommitSha]), + ); + }, + }); + } + + this.deps.onProgress("pushing", "Pushing to remote..."); + + const syncStatus = await this.readOnlyStep("check-sync-status", () => + this.deps.getSyncStatus(directoryPath), + ); + + await this.step({ + name: "pushing", + execute: async () => { + const result = syncStatus.hasRemote + ? await this.deps.push(directoryPath) + : await this.deps.publish(directoryPath); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => {}, // no meaningful rollback can happen here w/o force push + }); + + if ((!prTitle || !prBody) && credentials) { + this.deps.onProgress("creating-pr", "Generating PR description..."); + const generated = await this.readOnlyStep( + "generate-pr-description", + async () => { + try { + return await this.deps.generatePrTitleAndBody( + directoryPath, + credentials, + ); + } catch { + return null; + } + }, + ); + if (generated) { + if (!prTitle) prTitle = generated.title; + if (!prBody) prBody = generated.body; + } + } + + this.deps.onProgress("creating-pr", "Creating pull request..."); + + const prResult = await this.step({ + name: "creating-pr", + execute: async () => { + const result = await this.deps.createPr( + directoryPath, + prTitle || undefined, + prBody || undefined, + draft, + ); + if (!result.success) throw new Error(result.message); + return result; + }, + rollback: async () => {}, + }); + + return { prUrl: prResult.prUrl }; + } +} diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 310856ada..99c2421d7 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -228,9 +228,18 @@ export type PrStatusOutput = z.infer; // Create PR operation export const createPrInput = z.object({ directoryPath: z.string(), - title: z.string().optional(), - body: z.string().optional(), + flowId: z.string(), + branchName: z.string().optional(), + commitMessage: z.string().optional(), + prTitle: z.string().optional(), + prBody: z.string().optional(), draft: z.boolean().optional(), + credentials: z + .object({ + apiKey: z.string(), + apiHost: z.string(), + }) + .optional(), }); export type CreatePrInput = z.infer; @@ -380,10 +389,22 @@ export const syncOutput = z.object({ export type SyncOutput = z.infer; +export const createPrStep = z.enum([ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + "error", +]); + +export type CreatePrStep = z.infer; + export const createPrOutput = z.object({ success: z.boolean(), message: z.string(), prUrl: z.string().nullable(), + failedStep: createPrStep.nullable(), state: gitStateSnapshotSchema.optional(), }); @@ -414,3 +435,12 @@ export const searchGithubIssuesInput = z.object({ }); export const searchGithubIssuesOutput = z.array(githubIssueSchema); + +export const createPrProgressPayload = z.object({ + flowId: z.string(), + step: createPrStep, + message: z.string(), + prUrl: z.string().optional(), +}); + +export type CreatePrProgressPayload = z.infer; diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 740071fcf..790c631dd 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -32,11 +32,13 @@ import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { LlmCredentials } from "../llm-gateway/schemas"; import type { LlmGatewayService } from "../llm-gateway/service"; +import { CreatePrSaga } from "./create-pr-saga"; import type { ChangedFile, CloneProgressPayload, CommitOutput, CreatePrOutput, + CreatePrProgressPayload, DetectRepoResult, DiffStats, DiscardFileChangesOutput, @@ -61,10 +63,12 @@ const fsPromises = fs.promises; export const GitServiceEvent = { CloneProgress: "cloneProgress", + CreatePrProgress: "createPrProgress", } as const; export interface GitServiceEvents { [GitServiceEvent.CloneProgress]: CloneProgressPayload; + [GitServiceEvent.CreatePrProgress]: CreatePrProgressPayload; } const log = logger.scope("git-service"); @@ -460,6 +464,91 @@ export class GitService extends TypedEventEmitter { }; } + public async createPr(input: { + directoryPath: string; + flowId: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + credentials?: { apiKey: string; apiHost: string }; + }): Promise { + const { directoryPath, flowId } = input; + + const emitProgress = ( + step: CreatePrProgressPayload["step"], + message: string, + prUrl?: string, + ) => { + this.emit(GitServiceEvent.CreatePrProgress, { + flowId, + step, + message, + prUrl, + }); + }; + + const saga = new CreatePrSaga( + { + getCurrentBranch: (dir) => getCurrentBranch(dir), + createBranch: (dir, name) => this.createBranch(dir, name), + checkoutBranch: (dir, name) => this.checkoutBranch(dir, name), + getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), + generateCommitMessage: (dir, creds) => + this.generateCommitMessage(dir, creds), + commit: (dir, msg) => this.commit(dir, msg), + getSyncStatus: (dir) => this.getGitSyncStatus(dir), + push: (dir) => this.push(dir), + publish: (dir) => this.publish(dir), + generatePrTitleAndBody: (dir, creds) => + this.generatePrTitleAndBody(dir, creds), + createPr: (dir, title, body, draft) => + this.createPrViaGh(dir, title, body, draft), + onProgress: emitProgress, + }, + log, + ); + + const result = await saga.run({ + directoryPath, + branchName: input.branchName, + commitMessage: input.commitMessage, + prTitle: input.prTitle, + prBody: input.prBody, + draft: input.draft, + credentials: input.credentials, + }); + + if (!result.success) { + emitProgress("error", result.error); + return { + success: false, + message: result.error, + prUrl: null, + failedStep: result.failedStep as CreatePrOutput["failedStep"], + }; + } + + const state = await this.getStateSnapshot(directoryPath, { + includePrStatus: true, + }); + + emitProgress( + "complete", + "Pull request created", + result.data.prUrl ?? undefined, + ); + + return { + success: true, + message: "Pull request created", + prUrl: result.data.prUrl, + failedStep: null, + state, + }; + } + public async getPrTemplate( directoryPath: string, ): Promise { @@ -629,12 +718,12 @@ export class GitService extends TypedEventEmitter { } } - public async createPr( + private async createPrViaGh( directoryPath: string, title?: string, body?: string, draft?: boolean, - ): Promise { + ): Promise<{ success: boolean; message: string; prUrl: string | null }> { const args = ["pr", "create"]; if (title) { args.push("--title", title); @@ -656,18 +745,10 @@ export class GitService extends TypedEventEmitter { const prUrlMatch = result.stdout.match(/https:\/\/github\.com\/[^\s]+/); const prUrl = prUrlMatch?.[0] ?? null; - const state = await this.getStateSnapshot(directoryPath, { - includeChangedFiles: false, - includeDiffStats: false, - includeLatestCommit: false, - includePrStatus: true, - }); - return { success: true, message: "Pull request created", prUrl, - state, }; } diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 384bc1c96..d91d4319d 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -233,14 +233,7 @@ export const gitRouter = router({ createPr: publicProcedure .input(createPrInput) .output(createPrOutput) - .mutation(({ input }) => - getService().createPr( - input.directoryPath, - input.title, - input.body, - input.draft, - ), - ), + .mutation(({ input }) => getService().createPr(input)), openPr: publicProcedure .input(openPrInput) @@ -301,4 +294,14 @@ export const gitRouter = router({ input.limit, ), ), + + onCreatePrProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(GitServiceEvent.CreatePrProgress, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), }); diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx new file mode 100644 index 000000000..306d4f1eb --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx @@ -0,0 +1,347 @@ +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrStep } from "@features/git-interaction/types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreatePrDialog } from "./CreatePrDialog"; + +function setStoreState(overrides: { + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + createPrOpen?: boolean; + createPrStep?: CreatePrStep; + createPrError?: string | null; + createPrNeedsBranch?: boolean; + createPrNeedsCommit?: boolean; + createPrBaseBranch?: string | null; + createPrDraft?: boolean; + createPrFailedStep?: CreatePrStep | null; + isGeneratingCommitMessage?: boolean; + isGeneratingPr?: boolean; + isSubmitting?: boolean; +}) { + useGitInteractionStore.setState({ + branchName: "", + commitMessage: "", + prTitle: "", + prBody: "", + createPrOpen: true, + createPrStep: "idle", + createPrError: null, + createPrNeedsBranch: false, + createPrNeedsCommit: false, + createPrBaseBranch: null, + createPrDraft: false, + createPrFailedStep: null, + isGeneratingCommitMessage: false, + isGeneratingPr: false, + isSubmitting: false, + ...overrides, + }); +} + +const noop = () => {}; + +const meta: Meta = { + title: "Git/CreatePrDialog", + component: CreatePrDialog, + parameters: { layout: "centered" }, +}; + +export default meta; + +export const SetupFull: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrBaseBranch: "main", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupCommitOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupPushOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({}); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithDraft: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrDraft: true, + prTitle: "Add user authentication", + prBody: "Closes #123", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrError: "Branch name is required.", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupGeneratingCommitMessage: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + isGeneratingCommitMessage: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupGeneratingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + isGeneratingPr: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingBranch: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "creating-branch", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCommitting: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "committing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingPushing: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrStep: "pushing", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsCommit: true, + createPrStep: "creating-pr", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrNeedsBranch: true, + createPrNeedsCommit: true, + createPrStep: "error", + createPrError: "Failed to push: remote rejected (permission denied)", + createPrFailedStep: "pushing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx new file mode 100644 index 000000000..d044f8432 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx @@ -0,0 +1,322 @@ +import { + ErrorContainer, + GenerateButton, +} from "@features/git-interaction/components/GitInteractionDialogs"; +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrStep } from "@features/git-interaction/types"; +import { + CheckCircle, + Circle, + GitPullRequest, + XCircle, +} from "@phosphor-icons/react"; +import { + Button, + Checkbox, + Dialog, + Flex, + Spinner, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; + +const ICON_SIZE = 14; + +interface StepDef { + id: CreatePrStep; + label: string; +} + +function StepIndicator({ + steps, + currentStep, + failedStep, +}: { + steps: StepDef[]; + currentStep: CreatePrStep; + failedStep?: CreatePrStep | null; +}) { + const stepOrder: CreatePrStep[] = [ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + ]; + + const currentIndex = stepOrder.indexOf(currentStep); + const isError = currentStep === "error"; + + return ( + + {steps.map((step) => { + const stepIndex = stepOrder.indexOf(step.id); + const isComplete = + currentStep === "complete" || stepIndex < currentIndex; + const isActive = step.id === currentStep; + const isFailed = isError && step.id === failedStep; + + let icon: React.ReactNode; + if (isFailed) { + icon = ; + } else if (isComplete) { + icon = ; + } else if (isActive) { + icon = ; + } else { + icon = ; + } + + return ( + + + {icon} + + + {step.label} + + + ); + })} + + ); +} + +export interface CreatePrDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentBranch: string | null; + diffStats: { filesChanged: number; linesAdded: number; linesRemoved: number }; + isSubmitting: boolean; + onSubmit: () => void; + onGenerateCommitMessage: () => void; + onGeneratePr: () => void; +} + +export function CreatePrDialog({ + open, + onOpenChange, + currentBranch, + diffStats, + isSubmitting, + onSubmit, + onGenerateCommitMessage, + onGeneratePr, +}: CreatePrDialogProps) { + const store = useGitInteractionStore(); + const { actions } = store; + + const { createPrStep: step } = store; + const isExecuting = step !== "idle" && step !== "complete"; + + // Build the step list based on what's needed + const steps: StepDef[] = []; + if (store.createPrNeedsBranch) { + steps.push({ + id: "creating-branch", + label: `Create branch ${store.branchName || ""}`.trim(), + }); + } + if (store.createPrNeedsCommit) { + steps.push({ id: "committing", label: "Commit changes" }); + } + steps.push({ id: "pushing", label: "Push to remote" }); + steps.push({ id: "creating-pr", label: "Create pull request" }); + + return ( + + + + + + + {isExecuting ? "Creating PR..." : "Create PR"} + + + + {!isExecuting && ( + <> + {store.createPrNeedsBranch && ( + + + Branch + + actions.setBranchName(e.target.value)} + placeholder="branch-name" + size="1" + autoFocus + /> + {currentBranch && ( + + from {currentBranch} + + )} + + )} + + {store.createPrNeedsCommit && ( + + + + Commit message + + + + {diffStats.filesChanged} file + {diffStats.filesChanged === 1 ? "" : "s"} + + + +{diffStats.linesAdded} + + + -{diffStats.linesRemoved} + + + + +