Skip to content

Commit 01efafa

Browse files
committed
feat(code): unified PR creation workflow
1 parent e4eebfb commit 01efafa

File tree

14 files changed

+1328
-512
lines changed

14 files changed

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

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,18 @@ export type PrStatusOutput = z.infer<typeof prStatusOutput>;
228228
// Create PR operation
229229
export const createPrInput = z.object({
230230
directoryPath: z.string(),
231-
title: z.string().optional(),
232-
body: z.string().optional(),
231+
flowId: z.string(),
232+
branchName: z.string().optional(),
233+
commitMessage: z.string().optional(),
234+
prTitle: z.string().optional(),
235+
prBody: z.string().optional(),
233236
draft: z.boolean().optional(),
237+
credentials: z
238+
.object({
239+
apiKey: z.string(),
240+
apiHost: z.string(),
241+
})
242+
.optional(),
234243
});
235244

236245
export type CreatePrInput = z.infer<typeof createPrInput>;
@@ -380,10 +389,22 @@ export const syncOutput = z.object({
380389

381390
export type SyncOutput = z.infer<typeof syncOutput>;
382391

392+
export const createPrStep = z.enum([
393+
"creating-branch",
394+
"committing",
395+
"pushing",
396+
"creating-pr",
397+
"complete",
398+
"error",
399+
]);
400+
401+
export type CreatePrStep = z.infer<typeof createPrStep>;
402+
383403
export const createPrOutput = z.object({
384404
success: z.boolean(),
385405
message: z.string(),
386406
prUrl: z.string().nullable(),
407+
failedStep: createPrStep.nullable(),
387408
state: gitStateSnapshotSchema.optional(),
388409
});
389410

@@ -414,3 +435,12 @@ export const searchGithubIssuesInput = z.object({
414435
});
415436

416437
export const searchGithubIssuesOutput = z.array(githubIssueSchema);
438+
439+
export const createPrProgressPayload = z.object({
440+
flowId: z.string(),
441+
step: createPrStep,
442+
message: z.string(),
443+
prUrl: z.string().optional(),
444+
});
445+
446+
export type CreatePrProgressPayload = z.infer<typeof createPrProgressPayload>;

0 commit comments

Comments
 (0)