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
212 changes: 212 additions & 0 deletions apps/code/src/main/services/git/create-pr-flow-saga.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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<string | null>;
createBranch(dir: string, name: string): Promise<void>;
checkoutBranch(
dir: string,
name: string,
): Promise<{ previousBranch: string; currentBranch: string }>;
getChangedFilesHead(dir: string): Promise<ChangedFile[]>;
generateCommitMessage(
dir: string,
credentials: LlmCredentials,
): Promise<{ message: string }>;
commit(dir: string, message: string): Promise<CommitOutput>;
getSyncStatus(dir: string): Promise<GitSyncStatus>;
push(dir: string): Promise<PushOutput>;
publish(dir: string): Promise<PublishOutput>;
generatePrTitleAndBody(
dir: string,
credentials: LlmCredentials,
): Promise<{ title: string; body: string }>;
createPr(
dir: string,
title?: string,
body?: string,
draft?: boolean,
): Promise<CreatePrOutput>;
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<CreatePrFlowSagaOutput> {
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) {
// Generate commit message if not provided
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;
},
// Push can't be meaningfully rolled back without force-push
rollback: async () => {},
});

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;
},
// PR creation doesn't need rollback — the branch/commit are already pushed
rollback: async () => {},
});

return { prUrl: prResult.prUrl };
}
}
51 changes: 51 additions & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,54 @@ export const searchGithubIssuesInput = z.object({
});

export const searchGithubIssuesOutput = z.array(githubIssueSchema);

// Create PR flow (composite operation)
export const createPrFlowStep = z.enum([
"creating-branch",
"committing",
"pushing",
"creating-pr",
"complete",
"error",
]);

export type CreatePrFlowStep = z.infer<typeof createPrFlowStep>;

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<typeof createPrFlowInput>;

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<typeof createPrFlowOutput>;
Loading
Loading