Skip to content

Commit 3ee1fbf

Browse files
committed
sequential merge and track pr linking
1 parent 863ca51 commit 3ee1fbf

9 files changed

Lines changed: 244 additions & 116 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ jobs:
2727
node-version: 22
2828
cache: "pnpm"
2929

30+
- name: Setup Bun
31+
uses: oven-sh/setup-bun@v2
32+
3033
- name: Install dependencies
3134
run: pnpm install --frozen-lockfile
3235

apps/cli/src/commands/merge.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
formatSuccess,
1313
hint,
1414
message,
15-
status,
1615
warning,
1716
} from "../utils/output";
1817
import { unwrap } from "../utils/run";
@@ -24,8 +23,6 @@ interface MergeFlags {
2423
}
2524

2625
export async function merge(flags: MergeFlags, ctx: ArrContext): Promise<void> {
27-
const trunk = ctx.trunk;
28-
2926
const prsResult = await getMergeablePrs();
3027

3128
if (!prsResult.ok) {
@@ -62,25 +59,22 @@ export async function merge(flags: MergeFlags, ctx: ArrContext): Promise<void> {
6259
if (flags.merge) method = "merge";
6360
if (flags.rebase) method = "rebase";
6461

65-
message(`Merging ${prs.length} PR${prs.length > 1 ? "s" : ""} from stack...`);
62+
message(`Merging ${prs.length} PR${prs.length > 1 ? "s" : ""}...`);
6663
blank();
6764

6865
const result = await mergeCmd(prs, {
6966
method,
7067
engine: ctx.engine,
71-
onMerging: (pr: PRToMerge, nextPr?: PRToMerge) => {
72-
message(`Merging PR #${cyan(String(pr.prNumber))}: ${pr.prTitle}`);
73-
hint(`Branch: ${pr.bookmarkName}${pr.baseRefName}`);
74-
if (nextPr) {
75-
hint(`Rebasing PR #${nextPr.prNumber} onto ${trunk}...`);
76-
}
68+
onWaitingForCI: (pr: PRToMerge) => {
69+
message(`PR #${cyan(String(pr.prNumber))}: ${pr.prTitle}`);
70+
message(dim(" Waiting for CI checks..."));
7771
},
78-
onWaiting: () => {
79-
process.stdout.write(dim(" Waiting for GitHub..."));
72+
onMerging: (_pr: PRToMerge) => {
73+
message(dim(" Merging..."));
8074
},
8175
onMerged: (pr: PRToMerge) => {
82-
process.stdout.write(`\r${" ".repeat(30)}\r`);
83-
message(formatSuccess(`Merged PR #${pr.prNumber}`));
76+
message(formatSuccess(` Merged PR #${pr.prNumber}`));
77+
blank();
8478
},
8579
});
8680

@@ -89,7 +83,10 @@ export async function merge(flags: MergeFlags, ctx: ArrContext): Promise<void> {
8983
process.exit(1);
9084
}
9185

92-
blank();
93-
status("Syncing to update local state...");
94-
message(formatSuccess("Done! All PRs merged and synced."));
86+
message(
87+
formatSuccess(
88+
`Merged ${result.value.merged.length} PR${result.value.merged.length > 1 ? "s" : ""}!`,
89+
),
90+
);
91+
hint("Run 'arr sync' to update local state.");
9592
}

apps/cli/src/commands/track.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@ export async function track(
1616

1717
message(formatSuccess(`Now tracking ${cyan(result.bookmark)}`));
1818
indent(`${dim("Parent:")} ${result.parent}`);
19+
if (result.linkedPr) {
20+
indent(`${dim("Linked:")} PR #${result.linkedPr}`);
21+
}
1922
}

packages/core/src/commands/merge.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type { Command } from "./types";
77
interface MergeOptions {
88
method?: "merge" | "squash" | "rebase";
99
engine: Engine;
10-
onMerging?: (pr: PRToMerge, nextPr?: PRToMerge) => void;
11-
onWaiting?: () => void;
10+
onWaitingForCI?: (pr: PRToMerge) => void;
11+
onMerging?: (pr: PRToMerge) => void;
1212
onMerged?: (pr: PRToMerge) => void;
1313
}
1414

@@ -20,7 +20,8 @@ export async function getMergeablePrs(): Promise<Result<PRToMerge[]>> {
2020
}
2121

2222
/**
23-
* Merge the stack of PRs.
23+
* Merge the stack of PRs sequentially.
24+
* Waits for CI to pass on each PR before merging, then updates the next PR's base.
2425
* Untracks merged bookmarks from the engine.
2526
*/
2627
export async function merge(
@@ -31,8 +32,8 @@ export async function merge(
3132
prs,
3233
{ method: options.method ?? "squash", engine: options.engine },
3334
{
35+
onWaitingForCI: options.onWaitingForCI,
3436
onMerging: options.onMerging,
35-
onWaiting: options.onWaiting,
3637
onMerged: options.onMerged,
3738
},
3839
);

packages/core/src/commands/track.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Engine } from "../engine";
2+
import { getPRForBranch } from "../github/pr-status";
23
import { ensureBookmark, getTrunk, list, resolveChange } from "../jj";
34
import { createError, err, ok, type Result } from "../result";
45
import { datePrefixedLabel } from "../slugify";
@@ -7,6 +8,8 @@ import type { Command } from "./types";
78
interface TrackResult {
89
bookmark: string;
910
parent: string;
11+
/** PR number if an existing PR was found and linked */
12+
linkedPr?: number;
1013
}
1114

1215
interface TrackOptions {
@@ -126,7 +129,14 @@ export async function track(
126129
return refreshResult;
127130
}
128131

129-
return ok({ bookmark, parent: parentBranch });
132+
// Check if this bookmark has an existing PR on GitHub
133+
let linkedPr: number | undefined;
134+
const prResult = await getPRForBranch(bookmark);
135+
if (prResult.ok && prResult.value && prResult.value.state === "OPEN") {
136+
linkedPr = prResult.value.number;
137+
}
138+
139+
return ok({ bookmark, parent: parentBranch, linkedPr });
130140
}
131141

132142
export const trackCommand: Command<TrackResult, [TrackOptions]> = {

packages/core/src/github/pr-actions.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,19 +194,31 @@ export async function updatePRBranch(
194194
}
195195
}
196196

197+
export interface WaitForMergeableOptions {
198+
timeoutMs?: number;
199+
pollIntervalMs?: number;
200+
/** Callback when status changes, for UI updates */
201+
onStatusChange?: (status: {
202+
mergeable: boolean | null;
203+
state: string;
204+
checksComplete: boolean;
205+
}) => void;
206+
}
207+
197208
export function waitForMergeable(
198209
prNumber: number,
199-
options?: { timeoutMs?: number; pollIntervalMs?: number },
210+
options?: WaitForMergeableOptions,
200211
cwd = process.cwd(),
201212
): Promise<Result<{ mergeable: boolean; reason?: string }>> {
202-
const timeoutMs = options?.timeoutMs ?? 30000;
203-
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
213+
const timeoutMs = options?.timeoutMs ?? 300000; // 5 minutes default
214+
const pollIntervalMs = options?.pollIntervalMs ?? 5000;
204215

205216
return withGitHub(
206217
cwd,
207218
"check mergeable status",
208219
async ({ octokit, owner, repo }) => {
209220
const startTime = Date.now();
221+
let lastState = "";
210222

211223
while (Date.now() - startTime < timeoutMs) {
212224
const { data: pr } = await octokit.pulls.get({
@@ -215,23 +227,58 @@ export function waitForMergeable(
215227
pull_number: prNumber,
216228
});
217229

218-
if (pr.mergeable === true) {
230+
// mergeable_state values:
231+
// - "clean": can merge, all checks passed
232+
// - "blocked": checks pending or required reviews missing
233+
// - "dirty": has conflicts
234+
// - "unstable": has failing checks but can still merge
235+
// - "unknown": GitHub is computing
236+
const state = pr.mergeable_state || "unknown";
237+
const checksComplete = state !== "blocked" && state !== "unknown";
238+
239+
// Notify caller of status change
240+
if (state !== lastState) {
241+
options?.onStatusChange?.({
242+
mergeable: pr.mergeable,
243+
state,
244+
checksComplete,
245+
});
246+
lastState = state;
247+
}
248+
249+
// "clean" means mergeable AND all required checks passed
250+
if (state === "clean" && pr.mergeable === true) {
219251
return { mergeable: true };
220252
}
221253

222-
if (pr.mergeable === false) {
254+
// "unstable" means checks failed but PR is still mergeable (non-required checks)
255+
if (state === "unstable" && pr.mergeable === true) {
256+
return { mergeable: true };
257+
}
258+
259+
// Has conflicts
260+
if (state === "dirty") {
261+
return {
262+
mergeable: false,
263+
reason: "Has merge conflicts",
264+
};
265+
}
266+
267+
// Explicit not mergeable
268+
if (pr.mergeable === false && state !== "unknown") {
223269
return {
224270
mergeable: false,
225-
reason: pr.mergeable_state || "Has conflicts or other issues",
271+
reason: state || "Not mergeable",
226272
};
227273
}
228274

275+
// "blocked" or "unknown" - keep waiting
229276
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
230277
}
231278

232279
return {
233280
mergeable: false,
234-
reason: "Timeout waiting for merge status",
281+
reason: "Timeout waiting for CI checks",
235282
};
236283
},
237284
);

packages/core/src/result.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type JJErrorCode =
3333
| "ALREADY_MERGED"
3434
| "NOT_FOUND"
3535
| "EMPTY_CHANGE"
36+
| "CI_FAILED"
3637
| "UNKNOWN";
3738

3839
export function createError(

0 commit comments

Comments
 (0)