Skip to content

Commit b86f01b

Browse files
committed
feat(code): unified PR creation workflow
1 parent ada314f commit b86f01b

21 files changed

Lines changed: 1466 additions & 551 deletions
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
if (!commitMessage && credentials) {
108+
this.deps.onProgress("committing", "Generating commit message...");
109+
const generated = await this.readOnlyStep(
110+
"generate-commit-message",
111+
async () => {
112+
try {
113+
return await this.deps.generateCommitMessage(
114+
directoryPath,
115+
credentials,
116+
);
117+
} catch {
118+
return null;
119+
}
120+
},
121+
);
122+
if (generated) commitMessage = generated.message;
123+
}
124+
125+
if (!commitMessage) {
126+
throw new Error("Commit message is required.");
127+
}
128+
129+
this.deps.onProgress("committing", "Committing changes...");
130+
131+
const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () =>
132+
getHeadSha(directoryPath),
133+
);
134+
135+
await this.step({
136+
name: "committing",
137+
execute: async () => {
138+
const result = await this.deps.commit(directoryPath, commitMessage!);
139+
if (!result.success) throw new Error(result.message);
140+
return result;
141+
},
142+
rollback: async () => {
143+
const manager = getGitOperationManager();
144+
await manager.executeWrite(directoryPath, (git) =>
145+
git.reset(["--soft", preCommitSha]),
146+
);
147+
},
148+
});
149+
}
150+
151+
this.deps.onProgress("pushing", "Pushing to remote...");
152+
153+
const syncStatus = await this.readOnlyStep("check-sync-status", () =>
154+
this.deps.getSyncStatus(directoryPath),
155+
);
156+
157+
await this.step({
158+
name: "pushing",
159+
execute: async () => {
160+
const result = syncStatus.hasRemote
161+
? await this.deps.push(directoryPath)
162+
: await this.deps.publish(directoryPath);
163+
if (!result.success) throw new Error(result.message);
164+
return result;
165+
},
166+
rollback: async () => {}, // no meaningful rollback can happen here w/o force push
167+
});
168+
169+
if ((!prTitle || !prBody) && credentials) {
170+
this.deps.onProgress("creating-pr", "Generating PR description...");
171+
const generated = await this.readOnlyStep(
172+
"generate-pr-description",
173+
async () => {
174+
try {
175+
return await this.deps.generatePrTitleAndBody(
176+
directoryPath,
177+
credentials,
178+
);
179+
} catch {
180+
return null;
181+
}
182+
},
183+
);
184+
if (generated) {
185+
if (!prTitle) prTitle = generated.title;
186+
if (!prBody) prBody = generated.body;
187+
}
188+
}
189+
190+
this.deps.onProgress("creating-pr", "Creating pull request...");
191+
192+
const prResult = await this.step({
193+
name: "creating-pr",
194+
execute: async () => {
195+
const result = await this.deps.createPr(
196+
directoryPath,
197+
prTitle || undefined,
198+
prBody || undefined,
199+
draft,
200+
);
201+
if (!result.success) throw new Error(result.message);
202+
return result;
203+
},
204+
rollback: async () => {},
205+
});
206+
207+
return { prUrl: prResult.prUrl };
208+
}
209+
}

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

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

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

0 commit comments

Comments
 (0)