Skip to content

Commit 6950f57

Browse files
committed
feat(code): unified PR creation workflow
1 parent 350c434 commit 6950f57

19 files changed

Lines changed: 1165 additions & 139 deletions
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { getGitOperationManager } from "@posthog/git/operation-manager";
2+
import { getHeadSha } from "@posthog/git/queries";
3+
import { Saga, type SagaLogger } from "@posthog/shared";
4+
import type { LlmCredentials } from "../llm-gateway/schemas";
5+
import type {
6+
ChangedFile,
7+
CommitOutput,
8+
CreatePrFlowProgressPayload,
9+
CreatePrOutput,
10+
GitSyncStatus,
11+
PublishOutput,
12+
PushOutput,
13+
} from "./schemas";
14+
15+
export interface CreatePrFlowSagaInput {
16+
directoryPath: string;
17+
branchName?: string;
18+
commitMessage?: string;
19+
prTitle?: string;
20+
prBody?: string;
21+
draft?: boolean;
22+
credentials?: LlmCredentials;
23+
}
24+
25+
export interface CreatePrFlowSagaOutput {
26+
prUrl: string | null;
27+
}
28+
29+
export interface CreatePrFlowDeps {
30+
getCurrentBranch(dir: string): Promise<string | null>;
31+
createBranch(dir: string, name: string): Promise<void>;
32+
checkoutBranch(
33+
dir: string,
34+
name: string,
35+
): Promise<{ previousBranch: string; currentBranch: string }>;
36+
getChangedFilesHead(dir: string): Promise<ChangedFile[]>;
37+
generateCommitMessage(
38+
dir: string,
39+
credentials: LlmCredentials,
40+
): Promise<{ message: string }>;
41+
commit(dir: string, message: string): Promise<CommitOutput>;
42+
getSyncStatus(dir: string): Promise<GitSyncStatus>;
43+
push(dir: string): Promise<PushOutput>;
44+
publish(dir: string): Promise<PublishOutput>;
45+
generatePrTitleAndBody(
46+
dir: string,
47+
credentials: LlmCredentials,
48+
): Promise<{ title: string; body: string }>;
49+
createPr(
50+
dir: string,
51+
title?: string,
52+
body?: string,
53+
draft?: boolean,
54+
): Promise<CreatePrOutput>;
55+
onProgress(
56+
step: CreatePrFlowProgressPayload["step"],
57+
message: string,
58+
prUrl?: string,
59+
): void;
60+
}
61+
62+
export class CreatePrFlowSaga extends Saga<
63+
CreatePrFlowSagaInput,
64+
CreatePrFlowSagaOutput
65+
> {
66+
readonly sagaName = "CreatePrFlowSaga";
67+
private deps: CreatePrFlowDeps;
68+
69+
constructor(deps: CreatePrFlowDeps, logger?: SagaLogger) {
70+
super(logger);
71+
this.deps = deps;
72+
}
73+
74+
protected async execute(
75+
input: CreatePrFlowSagaInput,
76+
): Promise<CreatePrFlowSagaOutput> {
77+
const { directoryPath, draft, credentials } = input;
78+
let { commitMessage, prTitle, prBody } = input;
79+
80+
if (input.branchName) {
81+
this.deps.onProgress(
82+
"creating-branch",
83+
`Creating branch ${input.branchName}...`,
84+
);
85+
86+
const originalBranch = await this.readOnlyStep(
87+
"get-original-branch",
88+
() => this.deps.getCurrentBranch(directoryPath),
89+
);
90+
91+
await this.step({
92+
name: "creating-branch",
93+
execute: () => this.deps.createBranch(directoryPath, input.branchName!),
94+
rollback: async () => {
95+
if (originalBranch) {
96+
await this.deps.checkoutBranch(directoryPath, originalBranch);
97+
}
98+
},
99+
});
100+
}
101+
102+
const changedFiles = await this.readOnlyStep("check-changes", () =>
103+
this.deps.getChangedFilesHead(directoryPath),
104+
);
105+
106+
if (changedFiles.length > 0) {
107+
// Generate commit message if not provided
108+
if (!commitMessage && credentials) {
109+
this.deps.onProgress("committing", "Generating commit message...");
110+
const generated = await this.readOnlyStep(
111+
"generate-commit-message",
112+
async () => {
113+
try {
114+
return await this.deps.generateCommitMessage(
115+
directoryPath,
116+
credentials,
117+
);
118+
} catch {
119+
return null;
120+
}
121+
},
122+
);
123+
if (generated) commitMessage = generated.message;
124+
}
125+
126+
if (!commitMessage) {
127+
throw new Error("Commit message is required.");
128+
}
129+
130+
this.deps.onProgress("committing", "Committing changes...");
131+
132+
const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () =>
133+
getHeadSha(directoryPath),
134+
);
135+
136+
await this.step({
137+
name: "committing",
138+
execute: async () => {
139+
const result = await this.deps.commit(directoryPath, commitMessage!);
140+
if (!result.success) throw new Error(result.message);
141+
return result;
142+
},
143+
rollback: async () => {
144+
const manager = getGitOperationManager();
145+
await manager.executeWrite(directoryPath, (git) =>
146+
git.reset(["--soft", preCommitSha]),
147+
);
148+
},
149+
});
150+
}
151+
152+
this.deps.onProgress("pushing", "Pushing to remote...");
153+
154+
const syncStatus = await this.readOnlyStep("check-sync-status", () =>
155+
this.deps.getSyncStatus(directoryPath),
156+
);
157+
158+
await this.step({
159+
name: "pushing",
160+
execute: async () => {
161+
const result = syncStatus.hasRemote
162+
? await this.deps.push(directoryPath)
163+
: await this.deps.publish(directoryPath);
164+
if (!result.success) throw new Error(result.message);
165+
return result;
166+
},
167+
// Push can't be meaningfully rolled back without force-push
168+
rollback: async () => {},
169+
});
170+
171+
if ((!prTitle || !prBody) && credentials) {
172+
this.deps.onProgress("creating-pr", "Generating PR description...");
173+
const generated = await this.readOnlyStep(
174+
"generate-pr-description",
175+
async () => {
176+
try {
177+
return await this.deps.generatePrTitleAndBody(
178+
directoryPath,
179+
credentials,
180+
);
181+
} catch {
182+
return null;
183+
}
184+
},
185+
);
186+
if (generated) {
187+
if (!prTitle) prTitle = generated.title;
188+
if (!prBody) prBody = generated.body;
189+
}
190+
}
191+
192+
this.deps.onProgress("creating-pr", "Creating pull request...");
193+
194+
const prResult = await this.step({
195+
name: "creating-pr",
196+
execute: async () => {
197+
const result = await this.deps.createPr(
198+
directoryPath,
199+
prTitle || undefined,
200+
prBody || undefined,
201+
draft,
202+
);
203+
if (!result.success) throw new Error(result.message);
204+
return result;
205+
},
206+
// PR creation doesn't need rollback — the branch/commit are already pushed
207+
rollback: async () => {},
208+
});
209+
210+
return { prUrl: prResult.prUrl };
211+
}
212+
}

apps/code/src/main/services/git/schemas.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,54 @@ export const searchGithubIssuesInput = z.object({
414414
});
415415

416416
export const searchGithubIssuesOutput = z.array(githubIssueSchema);
417+
418+
// Create PR flow (composite operation)
419+
export const createPrFlowStep = z.enum([
420+
"creating-branch",
421+
"committing",
422+
"pushing",
423+
"creating-pr",
424+
"complete",
425+
"error",
426+
]);
427+
428+
export type CreatePrFlowStep = z.infer<typeof createPrFlowStep>;
429+
430+
export const createPrFlowInput = z.object({
431+
directoryPath: z.string(),
432+
flowId: z.string(),
433+
branchName: z.string().optional(),
434+
commitMessage: z.string().optional(),
435+
prTitle: z.string().optional(),
436+
prBody: z.string().optional(),
437+
draft: z.boolean().optional(),
438+
credentials: z
439+
.object({
440+
apiKey: z.string(),
441+
apiHost: z.string(),
442+
})
443+
.optional(),
444+
});
445+
446+
export type CreatePrFlowInput = z.infer<typeof createPrFlowInput>;
447+
448+
export const createPrFlowProgressPayload = z.object({
449+
flowId: z.string(),
450+
step: createPrFlowStep,
451+
message: z.string(),
452+
prUrl: z.string().optional(),
453+
});
454+
455+
export type CreatePrFlowProgressPayload = z.infer<
456+
typeof createPrFlowProgressPayload
457+
>;
458+
459+
export const createPrFlowOutput = z.object({
460+
success: z.boolean(),
461+
message: z.string(),
462+
prUrl: z.string().nullable(),
463+
failedStep: createPrFlowStep.nullable(),
464+
state: gitStateSnapshotSchema.optional(),
465+
});
466+
467+
export type CreatePrFlowOutput = z.infer<typeof createPrFlowOutput>;

0 commit comments

Comments
 (0)