Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
"dev": "bun run ./bin/arr.ts",
"typecheck": "tsc --noEmit",
"test": "bun test --concurrent tests/unit tests/e2e/cli.test.ts",
"test:pty": "vitest run tests/e2e/pty.test.ts"
},
"devDependencies": {
Expand Down
31 changes: 14 additions & 17 deletions apps/cli/src/commands/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
formatSuccess,
hint,
message,
status,
warning,
} from "../utils/output";
import { unwrap } from "../utils/run";
Expand All @@ -24,8 +23,6 @@ interface MergeFlags {
}

export async function merge(flags: MergeFlags, ctx: ArrContext): Promise<void> {
const trunk = ctx.trunk;

const prsResult = await getMergeablePrs();

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

message(`Merging ${prs.length} PR${prs.length > 1 ? "s" : ""} from stack...`);
message(`Merging ${prs.length} PR${prs.length > 1 ? "s" : ""}...`);
blank();

const result = await mergeCmd(prs, {
method,
engine: ctx.engine,
onMerging: (pr: PRToMerge, nextPr?: PRToMerge) => {
message(`Merging PR #${cyan(String(pr.prNumber))}: ${pr.prTitle}`);
hint(`Branch: ${pr.bookmarkName} → ${pr.baseRefName}`);
if (nextPr) {
hint(`Rebasing PR #${nextPr.prNumber} onto ${trunk}...`);
}
onWaitingForCI: (pr: PRToMerge) => {
message(`PR #${cyan(String(pr.prNumber))}: ${pr.prTitle}`);
message(dim(" Waiting for CI checks..."));
},
onWaiting: () => {
process.stdout.write(dim(" Waiting for GitHub..."));
onMerging: (_pr: PRToMerge) => {
message(dim(" Merging..."));
},
onMerged: (pr: PRToMerge) => {
process.stdout.write(`\r${" ".repeat(30)}\r`);
message(formatSuccess(`Merged PR #${pr.prNumber}`));
message(formatSuccess(` Merged PR #${pr.prNumber}`));
blank();
},
});

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

blank();
status("Syncing to update local state...");
message(formatSuccess("Done! All PRs merged and synced."));
message(
formatSuccess(
`Merged ${result.value.merged.length} PR${result.value.merged.length > 1 ? "s" : ""}!`,
),
);
hint("Run 'arr sync' to update local state.");
}
3 changes: 3 additions & 0 deletions apps/cli/src/commands/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ export async function track(

message(formatSuccess(`Now tracking ${cyan(result.bookmark)}`));
indent(`${dim("Parent:")} ${result.parent}`);
if (result.linkedPr) {
indent(`${dim("Linked:")} PR #${result.linkedPr}`);
}
}
9 changes: 5 additions & 4 deletions packages/core/src/commands/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type { Command } from "./types";
interface MergeOptions {
method?: "merge" | "squash" | "rebase";
engine: Engine;
onMerging?: (pr: PRToMerge, nextPr?: PRToMerge) => void;
onWaiting?: () => void;
onWaitingForCI?: (pr: PRToMerge) => void;
onMerging?: (pr: PRToMerge) => void;
onMerged?: (pr: PRToMerge) => void;
}

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

/**
* Merge the stack of PRs.
* Merge the stack of PRs sequentially.
* Waits for CI to pass on each PR before merging, then updates the next PR's base.
* Untracks merged bookmarks from the engine.
*/
export async function merge(
Expand All @@ -31,8 +32,8 @@ export async function merge(
prs,
{ method: options.method ?? "squash", engine: options.engine },
{
onWaitingForCI: options.onWaitingForCI,
onMerging: options.onMerging,
onWaiting: options.onWaiting,
onMerged: options.onMerged,
},
);
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/commands/track.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Engine } from "../engine";
import { getPRForBranch } from "../github/pr-status";
import { ensureBookmark, getTrunk, list, resolveChange } from "../jj";
import { createError, err, ok, type Result } from "../result";
import { datePrefixedLabel } from "../slugify";
Expand All @@ -7,6 +8,8 @@ import type { Command } from "./types";
interface TrackResult {
bookmark: string;
parent: string;
/** PR number if an existing PR was found and linked */
linkedPr?: number;
}

interface TrackOptions {
Expand Down Expand Up @@ -126,7 +129,14 @@ export async function track(
return refreshResult;
}

return ok({ bookmark, parent: parentBranch });
// Check if this bookmark has an existing PR on GitHub
let linkedPr: number | undefined;
const prResult = await getPRForBranch(bookmark);
if (prResult.ok && prResult.value && prResult.value.state === "OPEN") {
linkedPr = prResult.value.number;
}

return ok({ bookmark, parent: parentBranch, linkedPr });
}

export const trackCommand: Command<TrackResult, [TrackOptions]> = {
Expand Down
61 changes: 54 additions & 7 deletions packages/core/src/github/pr-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,19 +194,31 @@ export async function updatePRBranch(
}
}

export interface WaitForMergeableOptions {
timeoutMs?: number;
pollIntervalMs?: number;
/** Callback when status changes, for UI updates */
onStatusChange?: (status: {
mergeable: boolean | null;
state: string;
checksComplete: boolean;
}) => void;
}

export function waitForMergeable(
prNumber: number,
options?: { timeoutMs?: number; pollIntervalMs?: number },
options?: WaitForMergeableOptions,
cwd = process.cwd(),
): Promise<Result<{ mergeable: boolean; reason?: string }>> {
const timeoutMs = options?.timeoutMs ?? 30000;
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
const timeoutMs = options?.timeoutMs ?? 300000; // 5 minutes default
const pollIntervalMs = options?.pollIntervalMs ?? 5000;

return withGitHub(
cwd,
"check mergeable status",
async ({ octokit, owner, repo }) => {
const startTime = Date.now();
let lastState = "";

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

if (pr.mergeable === true) {
// mergeable_state values:
// - "clean": can merge, all checks passed
// - "blocked": checks pending or required reviews missing
// - "dirty": has conflicts
// - "unstable": has failing checks but can still merge
// - "unknown": GitHub is computing
const state = pr.mergeable_state || "unknown";
const checksComplete = state !== "blocked" && state !== "unknown";

// Notify caller of status change
if (state !== lastState) {
options?.onStatusChange?.({
mergeable: pr.mergeable,
state,
checksComplete,
});
lastState = state;
}

// "clean" means mergeable AND all required checks passed
if (state === "clean" && pr.mergeable === true) {
return { mergeable: true };
}

if (pr.mergeable === false) {
// "unstable" means checks failed but PR is still mergeable (non-required checks)
if (state === "unstable" && pr.mergeable === true) {
return { mergeable: true };
}

// Has conflicts
if (state === "dirty") {
return {
mergeable: false,
reason: "Has merge conflicts",
};
}

// Explicit not mergeable
if (pr.mergeable === false && state !== "unknown") {
return {
mergeable: false,
reason: pr.mergeable_state || "Has conflicts or other issues",
reason: state || "Not mergeable",
};
}

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

return {
mergeable: false,
reason: "Timeout waiting for merge status",
reason: "Timeout waiting for CI checks",
};
},
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type JJErrorCode =
| "ALREADY_MERGED"
| "NOT_FOUND"
| "EMPTY_CHANGE"
| "CI_FAILED"
| "UNKNOWN";

export function createError(
Expand Down
Loading