Skip to content

Commit 67e4f9f

Browse files
authored
feat(code): unified PR creation workflow (#1377)
## Problem the current method for getting changes shipped takes _a lot_ of clicks, and at least 3 separate modals -- create branch, commit, create PR. for what i expect to be the more general case ("agent did work, i wanna ship it"), we can simplify this <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## removed - existing "commit and create PR" flow from the commit dialog - existing standalone "create pr" action / dialog ## added introduces a new unified "create PR" workflow that does these steps, as needed: 1. create & checkout new branch 2. commit 3. push 4. create PR | full input - on default branch, changes to be committed | loading states | error state | | --- | --- | --- | | ![Screenshot 2026-03-31 at 9.27.33 AM.png](https://app.graphite.com/user-attachments/assets/fc1351a6-826a-44ae-999c-468e89d89e95.png)<br> | ![Screenshot 2026-03-31 at 9.27.47 AM.png](https://app.graphite.com/user-attachments/assets/a8efcc02-5dcb-4237-b9ad-056ca6eb913d.png)<br> | ![Screenshot 2026-03-31 at 9.27.52 AM.png](https://app.graphite.com/user-attachments/assets/684d9fb3-1168-41c0-a680-b0cba6de0a9a.png)<br> | ### create pr flow notes: - if the user is already on a non-default branch, the first step is optional - if the user has no uncommitted changes, the flow starts on step 3 - if a PR already exists on the current branch and there are new uncommited changes, the flow creates a new branch (stack time baby) - AI generation is delayed til the user clicks "create" - happens if the commit msg and/or pr details are left empty - primary CTA swaps to "view PR" when a PR exists, until new changes appear, then it swaps back to "create PR" - all existing individual actions are still available in the dropdown <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? _i have not yet tested this in cloud or worktrees_ manually tested, i know it's a big PR, so would appreciate others manually testing a bit too 💪 <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. -->
1 parent aaff4e0 commit 67e4f9f

File tree

14 files changed

+1282
-469
lines changed

14 files changed

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

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,11 @@ 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(),
234237
});
235238

@@ -372,10 +375,22 @@ export const syncOutput = z.object({
372375

373376
export type SyncOutput = z.infer<typeof syncOutput>;
374377

378+
export const createPrStep = z.enum([
379+
"creating-branch",
380+
"committing",
381+
"pushing",
382+
"creating-pr",
383+
"complete",
384+
"error",
385+
]);
386+
387+
export type CreatePrStep = z.infer<typeof createPrStep>;
388+
375389
export const createPrOutput = z.object({
376390
success: z.boolean(),
377391
message: z.string(),
378392
prUrl: z.string().nullable(),
393+
failedStep: createPrStep.nullable(),
379394
state: gitStateSnapshotSchema.optional(),
380395
});
381396

@@ -406,3 +421,12 @@ export const searchGithubIssuesInput = z.object({
406421
});
407422

408423
export const searchGithubIssuesOutput = z.array(githubIssueSchema);
424+
425+
export const createPrProgressPayload = z.object({
426+
flowId: z.string(),
427+
step: createPrStep,
428+
message: z.string(),
429+
prUrl: z.string().optional(),
430+
});
431+
432+
export type CreatePrProgressPayload = z.infer<typeof createPrProgressPayload>;

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

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import { MAIN_TOKENS } from "../../di/tokens";
3131
import { logger } from "../../utils/logger";
3232
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
3333
import type { LlmGatewayService } from "../llm-gateway/service";
34+
import { CreatePrSaga } from "./create-pr-saga";
3435
import type {
3536
ChangedFile,
3637
CloneProgressPayload,
3738
CommitOutput,
3839
CreatePrOutput,
40+
CreatePrProgressPayload,
3941
DetectRepoResult,
4042
DiffStats,
4143
DiscardFileChangesOutput,
@@ -60,10 +62,12 @@ const fsPromises = fs.promises;
6062

6163
export const GitServiceEvent = {
6264
CloneProgress: "cloneProgress",
65+
CreatePrProgress: "createPrProgress",
6366
} as const;
6467

6568
export interface GitServiceEvents {
6669
[GitServiceEvent.CloneProgress]: CloneProgressPayload;
70+
[GitServiceEvent.CreatePrProgress]: CreatePrProgressPayload;
6771
}
6872

6973
const log = logger.scope("git-service");
@@ -459,6 +463,87 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
459463
};
460464
}
461465

466+
public async createPr(input: {
467+
directoryPath: string;
468+
flowId: string;
469+
branchName?: string;
470+
commitMessage?: string;
471+
prTitle?: string;
472+
prBody?: string;
473+
draft?: boolean;
474+
}): Promise<CreatePrOutput> {
475+
const { directoryPath, flowId } = input;
476+
477+
const emitProgress = (
478+
step: CreatePrProgressPayload["step"],
479+
message: string,
480+
prUrl?: string,
481+
) => {
482+
this.emit(GitServiceEvent.CreatePrProgress, {
483+
flowId,
484+
step,
485+
message,
486+
prUrl,
487+
});
488+
};
489+
490+
const saga = new CreatePrSaga(
491+
{
492+
getCurrentBranch: (dir) => getCurrentBranch(dir),
493+
createBranch: (dir, name) => this.createBranch(dir, name),
494+
checkoutBranch: (dir, name) => this.checkoutBranch(dir, name),
495+
getChangedFilesHead: (dir) => this.getChangedFilesHead(dir),
496+
generateCommitMessage: (dir) => this.generateCommitMessage(dir),
497+
commit: (dir, msg) => this.commit(dir, msg),
498+
getSyncStatus: (dir) => this.getGitSyncStatus(dir),
499+
push: (dir) => this.push(dir),
500+
publish: (dir) => this.publish(dir),
501+
generatePrTitleAndBody: (dir) => this.generatePrTitleAndBody(dir),
502+
createPr: (dir, title, body, draft) =>
503+
this.createPrViaGh(dir, title, body, draft),
504+
onProgress: emitProgress,
505+
},
506+
log,
507+
);
508+
509+
const result = await saga.run({
510+
directoryPath,
511+
branchName: input.branchName,
512+
commitMessage: input.commitMessage,
513+
prTitle: input.prTitle,
514+
prBody: input.prBody,
515+
draft: input.draft,
516+
});
517+
518+
if (!result.success) {
519+
emitProgress("error", result.error);
520+
return {
521+
success: false,
522+
message: result.error,
523+
prUrl: null,
524+
failedStep: result.failedStep as CreatePrOutput["failedStep"],
525+
};
526+
}
527+
528+
const state = await this.getStateSnapshot(directoryPath, {
529+
includePrStatus: true,
530+
});
531+
532+
emitProgress(
533+
"complete",
534+
"Pull request created",
535+
result.data.prUrl ?? undefined,
536+
);
537+
538+
return {
539+
success: true,
540+
message: "Pull request created",
541+
prUrl: result.data.prUrl,
542+
failedStep: null,
543+
state,
544+
};
545+
}
546+
462547
public async getPrTemplate(
463548
directoryPath: string,
464549
): Promise<GetPrTemplateOutput> {
@@ -628,12 +713,12 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
628713
}
629714
}
630715

631-
public async createPr(
716+
private async createPrViaGh(
632717
directoryPath: string,
633718
title?: string,
634719
body?: string,
635720
draft?: boolean,
636-
): Promise<CreatePrOutput> {
721+
): Promise<{ success: boolean; message: string; prUrl: string | null }> {
637722
const args = ["pr", "create"];
638723
if (title) {
639724
args.push("--title", title);
@@ -655,18 +740,10 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
655740
const prUrlMatch = result.stdout.match(/https:\/\/github\.com\/[^\s]+/);
656741
const prUrl = prUrlMatch?.[0] ?? null;
657742

658-
const state = await this.getStateSnapshot(directoryPath, {
659-
includeChangedFiles: false,
660-
includeDiffStats: false,
661-
includeLatestCommit: false,
662-
includePrStatus: true,
663-
});
664-
665743
return {
666744
success: true,
667745
message: "Pull request created",
668746
prUrl,
669-
state,
670747
};
671748
}
672749

0 commit comments

Comments
 (0)