diff --git a/apps/code/src/main/services/git/create-pr-flow-saga.ts b/apps/code/src/main/services/git/create-pr-flow-saga.ts new file mode 100644 index 000000000..e884f9c7a --- /dev/null +++ b/apps/code/src/main/services/git/create-pr-flow-saga.ts @@ -0,0 +1,209 @@ +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, + CreatePrFlowProgressPayload, + CreatePrOutput, + GitSyncStatus, + PublishOutput, + PushOutput, +} from "./schemas"; + +export interface CreatePrFlowSagaInput { + directoryPath: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + credentials?: LlmCredentials; +} + +export interface CreatePrFlowSagaOutput { + prUrl: string | null; +} + +export interface CreatePrFlowDeps { + 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; + onProgress( + step: CreatePrFlowProgressPayload["step"], + message: string, + prUrl?: string, + ): void; +} + +export class CreatePrFlowSaga extends Saga< + CreatePrFlowSagaInput, + CreatePrFlowSagaOutput +> { + readonly sagaName = "CreatePrFlowSaga"; + private deps: CreatePrFlowDeps; + + constructor(deps: CreatePrFlowDeps, logger?: SagaLogger) { + super(logger); + this.deps = deps; + } + + protected async execute( + input: CreatePrFlowSagaInput, + ): 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..36d40b88c 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -414,3 +414,53 @@ export const searchGithubIssuesInput = z.object({ }); export const searchGithubIssuesOutput = z.array(githubIssueSchema); + +export const createPrFlowStep = z.enum([ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + "error", +]); + +export type CreatePrFlowStep = z.infer; + +export const createPrFlowInput = z.object({ + directoryPath: z.string(), + 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 CreatePrFlowInput = z.infer; + +export const createPrFlowProgressPayload = z.object({ + flowId: z.string(), + step: createPrFlowStep, + message: z.string(), + prUrl: z.string().optional(), +}); + +export type CreatePrFlowProgressPayload = z.infer< + typeof createPrFlowProgressPayload +>; + +export const createPrFlowOutput = z.object({ + success: z.boolean(), + message: z.string(), + prUrl: z.string().nullable(), + failedStep: createPrFlowStep.nullable(), + state: gitStateSnapshotSchema.optional(), +}); + +export type CreatePrFlowOutput = z.infer; diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 740071fcf..060446789 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -32,10 +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 { CreatePrFlowSaga } from "./create-pr-flow-saga"; import type { ChangedFile, CloneProgressPayload, CommitOutput, + CreatePrFlowOutput, + CreatePrFlowProgressPayload, CreatePrOutput, DetectRepoResult, DiffStats, @@ -61,10 +64,12 @@ const fsPromises = fs.promises; export const GitServiceEvent = { CloneProgress: "cloneProgress", + CreatePrFlowProgress: "createPrFlowProgress", } as const; export interface GitServiceEvents { [GitServiceEvent.CloneProgress]: CloneProgressPayload; + [GitServiceEvent.CreatePrFlowProgress]: CreatePrFlowProgressPayload; } const log = logger.scope("git-service"); @@ -460,6 +465,91 @@ export class GitService extends TypedEventEmitter { }; } + public async createPrFlow(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: CreatePrFlowProgressPayload["step"], + message: string, + prUrl?: string, + ) => { + this.emit(GitServiceEvent.CreatePrFlowProgress, { + flowId, + step, + message, + prUrl, + }); + }; + + const saga = new CreatePrFlowSaga( + { + 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.createPr(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 CreatePrFlowOutput["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 { diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 384bc1c96..b218e934b 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -9,6 +9,8 @@ import { commitInput, commitOutput, createBranchInput, + createPrFlowInput, + createPrFlowOutput, createPrInput, createPrOutput, detectRepoInput, @@ -301,4 +303,19 @@ export const gitRouter = router({ input.limit, ), ), + + createPrFlow: publicProcedure + .input(createPrFlowInput) + .output(createPrFlowOutput) + .mutation(({ input }) => getService().createPrFlow(input)), + + onCreatePrFlowProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(GitServiceEvent.CreatePrFlowProgress, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), }); diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.stories.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.stories.tsx new file mode 100644 index 000000000..e97ca2afb --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.stories.tsx @@ -0,0 +1,349 @@ +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrFlowStep } from "@features/git-interaction/types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreatePrFlowDialog } from "./CreatePrFlowDialog"; + +function setStoreState(overrides: { + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + createPrFlowOpen?: boolean; + createPrFlowStep?: CreatePrFlowStep; + createPrFlowError?: string | null; + createPrFlowNeedsBranch?: boolean; + createPrFlowNeedsCommit?: boolean; + createPrFlowBaseBranch?: string | null; + createPrFlowDraft?: boolean; + createPrFlowFailedStep?: CreatePrFlowStep | null; + isGeneratingCommitMessage?: boolean; + isGeneratingPr?: boolean; + isSubmitting?: boolean; +}) { + useGitInteractionStore.setState({ + branchName: "", + commitMessage: "", + prTitle: "", + prBody: "", + createPrFlowOpen: true, + createPrFlowStep: "idle", + createPrFlowError: null, + createPrFlowNeedsBranch: false, + createPrFlowNeedsCommit: false, + createPrFlowBaseBranch: null, + createPrFlowDraft: false, + createPrFlowFailedStep: null, + isGeneratingCommitMessage: false, + isGeneratingPr: false, + isSubmitting: false, + ...overrides, + }); +} + +const noop = () => {}; + +const meta: Meta = { + title: "Git/CreatePrFlowDialog", + component: CreatePrFlowDialog, + parameters: { layout: "centered" }, +}; + +export default meta; + +export const SetupFull: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsBranch: true, + createPrFlowNeedsCommit: true, + createPrFlowBaseBranch: "main", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupCommitOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupPushOnly: StoryObj = { + decorators: [ + (Story) => { + setStoreState({}); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithDraft: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + createPrFlowDraft: true, + prTitle: "Add user authentication", + prBody: "Closes #123", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupWithError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsBranch: true, + createPrFlowError: "Branch name is required.", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const SetupGeneratingCommitMessage: StoryObj = + { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + isGeneratingCommitMessage: true, + }); + return ; + }, + ], + render: () => ( + + ), + }; + +export const SetupGeneratingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + isGeneratingPr: true, + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingBranch: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsBranch: true, + createPrFlowNeedsCommit: true, + createPrFlowStep: "creating-branch", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCommitting: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsBranch: true, + createPrFlowNeedsCommit: true, + createPrFlowStep: "committing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingPushing: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + createPrFlowStep: "pushing", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingCreatingPr: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsCommit: true, + createPrFlowStep: "creating-pr", + }); + return ; + }, + ], + render: () => ( + + ), +}; + +export const ExecutingError: StoryObj = { + decorators: [ + (Story) => { + setStoreState({ + createPrFlowNeedsBranch: true, + createPrFlowNeedsCommit: true, + createPrFlowStep: "error", + createPrFlowError: + "Failed to push: remote rejected (permission denied)", + createPrFlowFailedStep: "pushing", + branchName: "feature/add-auth", + }); + return ; + }, + ], + render: () => ( + + ), +}; diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.tsx new file mode 100644 index 000000000..723e87850 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrFlowDialog.tsx @@ -0,0 +1,323 @@ +import { + ErrorContainer, + GenerateButton, +} from "@features/git-interaction/components/GitInteractionDialogs"; +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import type { CreatePrFlowStep } 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: CreatePrFlowStep; + label: string; +} + +function StepIndicator({ + steps, + currentStep, + failedStep, +}: { + steps: StepDef[]; + currentStep: CreatePrFlowStep; + failedStep?: CreatePrFlowStep | null; +}) { + const stepOrder: CreatePrFlowStep[] = [ + "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 CreatePrFlowDialogProps { + 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 CreatePrFlowDialog({ + open, + onOpenChange, + currentBranch, + diffStats, + isSubmitting, + onSubmit, + onGenerateCommitMessage, + onGeneratePr, +}: CreatePrFlowDialogProps) { + const store = useGitInteractionStore(); + const { actions } = store; + + const { createPrFlowStep: step } = store; + const isExecuting = step !== "idle" && step !== "complete"; + + // Build the step list based on what's needed + const steps: StepDef[] = []; + if (store.createPrFlowNeedsBranch) { + steps.push({ + id: "creating-branch", + label: `Create branch ${store.branchName || ""}`.trim(), + }); + } + if (store.createPrFlowNeedsCommit) { + 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.createPrFlowNeedsBranch && ( + + + Branch + + actions.setBranchName(e.target.value)} + placeholder="branch-name" + size="1" + autoFocus + /> + {currentBranch && ( + + from {currentBranch} + + )} + + )} + + {store.createPrFlowNeedsCommit && ( + + + + Commit message + + + + {diffStats.filesChanged} file + {diffStats.filesChanged === 1 ? "" : "s"} + + + +{diffStats.linesAdded} + + + -{diffStats.linesRemoved} + + + + +