Skip to content

Commit a9d34cd

Browse files
committed
feat(code): unified PR creation workflow
1 parent a27b8da commit a9d34cd

16 files changed

Lines changed: 1388 additions & 508 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>;

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ import { logger } from "../../utils/logger";
3232
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
3333
import type { LlmCredentials } from "../llm-gateway/schemas";
3434
import type { LlmGatewayService } from "../llm-gateway/service";
35+
import { CreatePrFlowSaga } from "./create-pr-flow-saga";
3536
import type {
3637
ChangedFile,
3738
CloneProgressPayload,
3839
CommitOutput,
40+
CreatePrFlowOutput,
41+
CreatePrFlowProgressPayload,
3942
CreatePrOutput,
4043
DetectRepoResult,
4144
DiffStats,
@@ -61,10 +64,12 @@ const fsPromises = fs.promises;
6164

6265
export const GitServiceEvent = {
6366
CloneProgress: "cloneProgress",
67+
CreatePrFlowProgress: "createPrFlowProgress",
6468
} as const;
6569

6670
export interface GitServiceEvents {
6771
[GitServiceEvent.CloneProgress]: CloneProgressPayload;
72+
[GitServiceEvent.CreatePrFlowProgress]: CreatePrFlowProgressPayload;
6873
}
6974

7075
const log = logger.scope("git-service");
@@ -460,6 +465,91 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
460465
};
461466
}
462467

468+
public async createPrFlow(input: {
469+
directoryPath: string;
470+
flowId: string;
471+
branchName?: string;
472+
commitMessage?: string;
473+
prTitle?: string;
474+
prBody?: string;
475+
draft?: boolean;
476+
credentials?: { apiKey: string; apiHost: string };
477+
}): Promise<CreatePrFlowOutput> {
478+
const { directoryPath, flowId } = input;
479+
480+
const emitProgress = (
481+
step: CreatePrFlowProgressPayload["step"],
482+
message: string,
483+
prUrl?: string,
484+
) => {
485+
this.emit(GitServiceEvent.CreatePrFlowProgress, {
486+
flowId,
487+
step,
488+
message,
489+
prUrl,
490+
});
491+
};
492+
493+
const saga = new CreatePrFlowSaga(
494+
{
495+
getCurrentBranch: (dir) => getCurrentBranch(dir),
496+
createBranch: (dir, name) => this.createBranch(dir, name),
497+
checkoutBranch: (dir, name) => this.checkoutBranch(dir, name),
498+
getChangedFilesHead: (dir) => this.getChangedFilesHead(dir),
499+
generateCommitMessage: (dir, creds) =>
500+
this.generateCommitMessage(dir, creds),
501+
commit: (dir, msg) => this.commit(dir, msg),
502+
getSyncStatus: (dir) => this.getGitSyncStatus(dir),
503+
push: (dir) => this.push(dir),
504+
publish: (dir) => this.publish(dir),
505+
generatePrTitleAndBody: (dir, creds) =>
506+
this.generatePrTitleAndBody(dir, creds),
507+
createPr: (dir, title, body, draft) =>
508+
this.createPr(dir, title, body, draft),
509+
onProgress: emitProgress,
510+
},
511+
log,
512+
);
513+
514+
const result = await saga.run({
515+
directoryPath,
516+
branchName: input.branchName,
517+
commitMessage: input.commitMessage,
518+
prTitle: input.prTitle,
519+
prBody: input.prBody,
520+
draft: input.draft,
521+
credentials: input.credentials,
522+
});
523+
524+
if (!result.success) {
525+
emitProgress("error", result.error);
526+
return {
527+
success: false,
528+
message: result.error,
529+
prUrl: null,
530+
failedStep: result.failedStep as CreatePrFlowOutput["failedStep"],
531+
};
532+
}
533+
534+
const state = await this.getStateSnapshot(directoryPath, {
535+
includePrStatus: true,
536+
});
537+
538+
emitProgress(
539+
"complete",
540+
"Pull request created",
541+
result.data.prUrl ?? undefined,
542+
);
543+
544+
return {
545+
success: true,
546+
message: "Pull request created",
547+
prUrl: result.data.prUrl,
548+
failedStep: null,
549+
state,
550+
};
551+
}
552+
463553
public async getPrTemplate(
464554
directoryPath: string,
465555
): Promise<GetPrTemplateOutput> {
@@ -636,8 +726,12 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
636726
draft?: boolean,
637727
): Promise<CreatePrOutput> {
638728
const args = ["pr", "create"];
639-
if (title) args.push("--title", title);
640-
if (body) args.push("--body", body);
729+
if (title) {
730+
args.push("--title", title);
731+
args.push("--body", body || "");
732+
} else {
733+
args.push("--fill");
734+
}
641735
if (draft) args.push("--draft");
642736

643737
const result = await execGh(args, { cwd: directoryPath });

0 commit comments

Comments
 (0)