feat(KB-093): add PR-first merge mode with configurable merge strategies
- Add mergeStrategy setting (fast-forward, squash, merge-commit) to config - Implement PR-first auto-completion flow that monitors PR merge status - Wire PR monitoring service to detect merge completion and trigger auto-close - Add PR status UI to dashboard with merge progress indicator - Update settings modal with merge strategy selector - Add changeset for PR-first merge mode feature
This commit is contained in:
@@ -52,8 +52,16 @@ const mockListen = vi.fn((port: number) => {
|
||||
return server;
|
||||
});
|
||||
|
||||
const MockGitHubClient = vi.fn().mockImplementation(() => ({
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
getPrMergeStatus: vi.fn(),
|
||||
mergePr: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kb/dashboard", () => ({
|
||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||
GitHubClient: MockGitHubClient,
|
||||
}));
|
||||
|
||||
// ── Mock @kb/engine ────────────────────────────────────────────────
|
||||
|
||||
@@ -17,10 +17,14 @@ function makeMockStore() {
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: false,
|
||||
mergeStrategy: "direct",
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn().mockResolvedValue({ column: "in-review", paused: false }),
|
||||
getTask: vi.fn().mockResolvedValue({ id: "KB-TEST", column: "in-review", paused: false, description: "Test task", log: [] }),
|
||||
moveTask: vi.fn().mockResolvedValue({}),
|
||||
updatePrInfo: vi.fn().mockResolvedValue({}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
@@ -35,6 +39,35 @@ vi.mock("@kb/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||
}));
|
||||
|
||||
// ── Hoisted shared mocks ───────────────────────────────────────────
|
||||
|
||||
const {
|
||||
mockExec,
|
||||
mockExecSync,
|
||||
mockFindPrForBranch,
|
||||
mockCreatePr,
|
||||
mockGetPrMergeStatus,
|
||||
mockMergePr,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExec: vi.fn((_command: string, callback?: () => void) => callback?.()),
|
||||
mockExecSync: vi.fn(() => ""),
|
||||
mockFindPrForBranch: vi.fn(),
|
||||
mockCreatePr: vi.fn(),
|
||||
mockGetPrMergeStatus: vi.fn(),
|
||||
mockMergePr: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mock node:child_process ────────────────────────────────────────
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...original,
|
||||
exec: mockExec,
|
||||
execSync: mockExecSync,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock @kb/dashboard ─────────────────────────────────────────────
|
||||
|
||||
/** Create a mock server (EventEmitter) that simulates net.Server behavior. */
|
||||
@@ -59,6 +92,12 @@ const mockListen = vi.fn((port: number) => {
|
||||
|
||||
vi.mock("@kb/dashboard", () => ({
|
||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Mock node:readline ──────────────────────────────────────────────
|
||||
@@ -94,6 +133,17 @@ vi.mock("@kb/engine", async (importOriginal) => {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
PrMonitor: vi.fn().mockImplementation(() => ({
|
||||
onNewComments: vi.fn(),
|
||||
startMonitoring: vi.fn(),
|
||||
stopMonitoring: vi.fn(),
|
||||
stopAll: vi.fn(),
|
||||
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
|
||||
updatePrInfo: vi.fn(),
|
||||
})),
|
||||
PrCommentHandler: vi.fn().mockImplementation(() => ({
|
||||
handleNewComments: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
aiMergeTask: vi.fn().mockImplementation(() => Promise.resolve({ merged: true })),
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
@@ -102,14 +152,294 @@ vi.mock("@kb/engine", async (importOriginal) => {
|
||||
|
||||
// ── Import module under test (after mocks) ──────────────────────────
|
||||
|
||||
const { runDashboard } = await import("./dashboard.js");
|
||||
const { runDashboard, processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./dashboard.js");
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
function resetGitHubMocks() {
|
||||
mockFindPrForBranch.mockReset();
|
||||
mockCreatePr.mockReset();
|
||||
mockGetPrMergeStatus.mockReset();
|
||||
mockMergePr.mockReset();
|
||||
|
||||
mockFindPrForBranch.mockResolvedValue(null);
|
||||
mockCreatePr.mockResolvedValue({
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "KB-TEST",
|
||||
headBranch: "kb/kb-test",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
});
|
||||
mockGetPrMergeStatus.mockResolvedValue({
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "KB-TEST",
|
||||
headBranch: "kb/kb-test",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
reviewDecision: null,
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: ["required checks not successful: ci (pending)"],
|
||||
});
|
||||
mockMergePr.mockResolvedValue({
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "merged",
|
||||
title: "KB-TEST",
|
||||
headBranch: "kb/kb-test",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubMocks();
|
||||
mockExecSync.mockReset();
|
||||
mockExecSync.mockReturnValue("");
|
||||
mockExec.mockClear();
|
||||
});
|
||||
|
||||
describe("PR merge helpers", () => {
|
||||
it("defaults mergeStrategy to direct when unset", () => {
|
||||
expect(getMergeStrategy({ mergeStrategy: undefined })).toBe("direct");
|
||||
});
|
||||
|
||||
it("uses pull-request mergeStrategy when configured", () => {
|
||||
expect(getMergeStrategy({ mergeStrategy: "pull-request" })).toBe("pull-request");
|
||||
});
|
||||
|
||||
it("uses kb/{task-id-lower} branch naming for pull requests", () => {
|
||||
expect(getTaskBranchName("KB-093")).toBe("kb/kb-093");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processPullRequestMergeTask", () => {
|
||||
it("creates and links a PR when task.prInfo is missing", async () => {
|
||||
const store = makeMockStore();
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-093",
|
||||
title: "Add support for creating pull requests",
|
||||
description: "Implement PR automation",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
worktree: "/tmp/kb-093",
|
||||
log: [],
|
||||
});
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "KB-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("waiting");
|
||||
expect(mockFindPrForBranch).toHaveBeenCalledWith({ head: "kb/kb-093", state: "all" });
|
||||
expect(mockCreatePr).toHaveBeenCalledWith({
|
||||
title: "KB-093: Add support for creating pull requests",
|
||||
body: "Automated PR for KB-093.\n\nImplement PR automation",
|
||||
head: "kb/kb-093",
|
||||
});
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
||||
"KB-093",
|
||||
expect.objectContaining({ number: 42, status: "open" }),
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-093", { status: "awaiting-pr-checks" });
|
||||
});
|
||||
|
||||
it("links an existing PR instead of creating a duplicate", async () => {
|
||||
const store = makeMockStore();
|
||||
const existingPr = {
|
||||
url: "https://github.com/owner/repo/pull/7",
|
||||
number: 7,
|
||||
status: "open" as const,
|
||||
title: "Existing PR",
|
||||
headBranch: "kb/kb-093",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
mockFindPrForBranch.mockResolvedValue(existingPr);
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-093",
|
||||
title: "Task",
|
||||
description: "Description",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
log: [],
|
||||
});
|
||||
|
||||
await processPullRequestMergeTask(store as any, "/repo", "KB-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
|
||||
expect(mockCreatePr).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-093",
|
||||
"Linked existing PR",
|
||||
"PR #7: https://github.com/owner/repo/pull/7",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a ready PR and finalizes task cleanup", async () => {
|
||||
const store = makeMockStore();
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-093",
|
||||
title: "Task",
|
||||
description: "Description",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
worktree: "/tmp/kb-093",
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "Task",
|
||||
headBranch: "kb/kb-093",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
log: [],
|
||||
});
|
||||
mockGetPrMergeStatus.mockResolvedValue({
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "Task",
|
||||
headBranch: "kb/kb-093",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
reviewDecision: "APPROVED",
|
||||
checks: [{ name: "ci", required: true, state: "success" }],
|
||||
mergeReady: true,
|
||||
blockingReasons: [],
|
||||
});
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "KB-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("merged");
|
||||
expect(mockMergePr).toHaveBeenCalledWith({ number: 42, method: "squash" });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-093", "done");
|
||||
expect(mockExecSync).toHaveBeenCalledWith('git worktree remove "/tmp/kb-093" --force', expect.any(Object));
|
||||
expect(mockExecSync).toHaveBeenCalledWith('git branch -d "kb/kb-093"', expect.any(Object));
|
||||
});
|
||||
|
||||
it("does not merge when required checks or reviews are blocking", async () => {
|
||||
const store = makeMockStore();
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-093",
|
||||
title: "Task",
|
||||
description: "Description",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "Task",
|
||||
headBranch: "kb/kb-093",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
log: [],
|
||||
});
|
||||
mockGetPrMergeStatus.mockResolvedValue({
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open",
|
||||
title: "Task",
|
||||
headBranch: "kb/kb-093",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
checks: [{ name: "ci", required: true, state: "pending" }],
|
||||
mergeReady: false,
|
||||
blockingReasons: ["changes requested review is active", "required checks not successful: ci (pending)"],
|
||||
});
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "KB-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("waiting");
|
||||
expect(mockMergePr).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-093", { status: "awaiting-pr-checks" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — PR-first auto-merge queue", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "pull-request",
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-093", column: "in-review", paused: false },
|
||||
]);
|
||||
mockStore.getTask.mockResolvedValue({
|
||||
id: "KB-093",
|
||||
title: "Task",
|
||||
description: "Description",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
log: [],
|
||||
});
|
||||
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
});
|
||||
|
||||
it("uses PR lifecycle instead of aiMergeTask when mergeStrategy is pull-request", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
expect(mockCreatePr).toHaveBeenCalledWith({
|
||||
title: "KB-093: Task",
|
||||
body: "Automated PR for KB-093.\n\nDescription",
|
||||
head: "kb/kb-093",
|
||||
});
|
||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — WorktreePool wiring", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
// Re-set TaskStore mock (clearAllMocks wipes implementations)
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
@@ -177,6 +507,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -254,6 +585,7 @@ describe("runDashboard — immediate resume on unpause", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -359,6 +691,7 @@ describe("runDashboard — engine pause/unpause cycle", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -400,6 +733,7 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
const engine = await import("@kb/engine");
|
||||
@@ -516,6 +850,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -630,6 +965,7 @@ describe("runDashboard — --paused flag", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -690,6 +1026,7 @@ describe("runDashboard — --paused flag", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -742,6 +1079,7 @@ describe("runDashboard — --dev mode", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
@@ -855,6 +1193,7 @@ describe("runDashboard — merge conflict retry logic", () => {
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
resetGitHubMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { createServer } from "@kb/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier } from "@kb/engine";
|
||||
import type { Settings, TaskDetail, PrInfo } from "@kb/core";
|
||||
import { createServer, GitHubClient } from "@kb/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler } from "@kb/engine";
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
@@ -73,6 +74,124 @@ export function promptForPort(defaultPort: number = 4040, input: NodeJS.Readable
|
||||
});
|
||||
}
|
||||
|
||||
export function getMergeStrategy(settings: Pick<Settings, "mergeStrategy">): NonNullable<Settings["mergeStrategy"]> {
|
||||
return settings.mergeStrategy ?? "direct";
|
||||
}
|
||||
|
||||
export function getTaskBranchName(taskId: string): string {
|
||||
return `kb/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function buildPullRequestTitle(task: Pick<TaskDetail, "id" | "title">): string {
|
||||
return task.title ? `${task.id}: ${task.title}` : task.id;
|
||||
}
|
||||
|
||||
function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): string {
|
||||
return [`Automated PR for ${task.id}.`, "", task.description].join("\n");
|
||||
}
|
||||
|
||||
function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "worktree">): void {
|
||||
const branch = getTaskBranchName(task.id);
|
||||
|
||||
if (task.worktree) {
|
||||
try {
|
||||
execSync(`git worktree remove \"${task.worktree}\" --force`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — worktree may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`git branch -d \"${branch}\"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
execSync(`git branch -D \"${branch}\"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — branch may already be gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function processPullRequestMergeTask(
|
||||
store: TaskStore,
|
||||
cwd: string,
|
||||
taskId: string,
|
||||
github: Pick<GitHubClient, "findPrForBranch" | "createPr" | "getPrMergeStatus" | "mergePr">,
|
||||
): Promise<"waiting" | "merged" | "skipped"> {
|
||||
const task = await store.getTask(taskId);
|
||||
if (task.column !== "in-review" || task.paused) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const branch = getTaskBranchName(task.id);
|
||||
let prInfo: PrInfo | undefined = task.prInfo;
|
||||
|
||||
if (!prInfo) {
|
||||
await store.updateTask(task.id, { status: "creating-pr" });
|
||||
|
||||
const existingPr = await github.findPrForBranch({ head: branch, state: "all" });
|
||||
prInfo = existingPr ?? await github.createPr({
|
||||
title: buildPullRequestTitle(task),
|
||||
body: buildPullRequestBody(task),
|
||||
head: branch,
|
||||
});
|
||||
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
existingPr ? "Linked existing PR" : "Created PR",
|
||||
`PR #${prInfo.number}: ${prInfo.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!prInfo) {
|
||||
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
||||
}
|
||||
|
||||
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
|
||||
const refreshedPrInfo: PrInfo = {
|
||||
...prInfo,
|
||||
...mergeStatus.prInfo,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
};
|
||||
await store.updatePrInfo(task.id, refreshedPrInfo);
|
||||
|
||||
if (mergeStatus.prInfo.status === "merged") {
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
if (!mergeStatus.mergeReady) {
|
||||
if (mergeStatus.prInfo.status === "open") {
|
||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||
} else {
|
||||
await store.updateTask(task.id, { status: null });
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
await store.updateTask(task.id, { status: "merging-pr" });
|
||||
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${mergedPr.number}: ${mergedPr.url}`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
export async function runDashboard(port: number, opts: { open?: boolean; paused?: boolean; dev?: boolean; interactive?: boolean } = {}) {
|
||||
// Handle interactive port selection
|
||||
let selectedPort = port;
|
||||
@@ -160,6 +279,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
// pause is deduplicated across concurrent agents.
|
||||
//
|
||||
const usageLimitPauser = new UsageLimitPauser(store);
|
||||
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
|
||||
|
||||
// AI-powered merge handler (used by the web UI for manual merges).
|
||||
// Wrapped with the shared semaphore so merges count toward the global
|
||||
@@ -238,52 +358,71 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
if (task.column !== "in-review" || task.paused) {
|
||||
continue;
|
||||
}
|
||||
console.log(`[auto-merge] Merging ${taskId}...`);
|
||||
await onMerge(taskId);
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged`);
|
||||
// Clear mergeRetries on success
|
||||
if (task.mergeRetries && task.mergeRetries > 0) {
|
||||
await store.updateTask(taskId, { mergeRetries: 0 });
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
if (mergeStrategy === "pull-request") {
|
||||
console.log(`[auto-merge] Processing PR flow for ${taskId}...`);
|
||||
const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient);
|
||||
if (result === "merged") {
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged via pull request`);
|
||||
} else if (result === "waiting") {
|
||||
console.log(`[auto-merge] … ${taskId} waiting on PR checks or reviews`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[auto-merge] Merging ${taskId}...`);
|
||||
await onMerge(taskId);
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged`);
|
||||
// Clear mergeRetries on success
|
||||
if (task.mergeRetries && task.mergeRetries > 0) {
|
||||
await store.updateTask(taskId, { mergeRetries: 0 });
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message ?? String(err);
|
||||
console.log(`[auto-merge] ✗ ${taskId}: ${errorMsg}`);
|
||||
|
||||
// Check if this is a conflict error and if we should retry
|
||||
const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict");
|
||||
const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true, mergeStrategy: "direct" as const }));
|
||||
const task = await store.getTask(taskId).catch(() => null);
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
|
||||
if (task && isConflictError) {
|
||||
const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true }));
|
||||
const currentRetries = task.mergeRetries ?? 0;
|
||||
const maxRetries = 3;
|
||||
if (mergeStrategy === "direct") {
|
||||
// Check if this is a conflict error and if we should retry
|
||||
const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict");
|
||||
|
||||
if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) {
|
||||
// Increment retry counter and re-enqueue with delay
|
||||
const newRetryCount = currentRetries + 1;
|
||||
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
|
||||
if (task && isConflictError) {
|
||||
const currentRetries = task.mergeRetries ?? 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
// Calculate exponential backoff delay: 5s, 10s, 20s
|
||||
const delayMs = 5000 * Math.pow(2, currentRetries);
|
||||
console.log(`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`);
|
||||
if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) {
|
||||
// Increment retry counter and re-enqueue with delay
|
||||
const newRetryCount = currentRetries + 1;
|
||||
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
|
||||
|
||||
setTimeout(() => {
|
||||
enqueueMerge(taskId);
|
||||
}, delayMs);
|
||||
} else {
|
||||
// Max retries exceeded or auto-resolve disabled - keep in in-review
|
||||
if (currentRetries >= maxRetries) {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`);
|
||||
// Calculate exponential backoff delay: 5s, 10s, 20s
|
||||
const delayMs = 5000 * Math.pow(2, currentRetries);
|
||||
console.log(`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`);
|
||||
|
||||
setTimeout(() => {
|
||||
enqueueMerge(taskId);
|
||||
}, delayMs);
|
||||
} else {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: autoResolveConflicts disabled — manual resolution required`);
|
||||
// Max retries exceeded or auto-resolve disabled - keep in in-review
|
||||
if (currentRetries >= maxRetries) {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`);
|
||||
} else {
|
||||
console.log(`[auto-merge] ⊘ ${taskId}: autoResolveConflicts disabled — manual resolution required`);
|
||||
}
|
||||
// Reset task status so it doesn't appear stuck as "merging" in the UI
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
// Reset task status so it doesn't appear stuck as "merging" in the UI
|
||||
} else {
|
||||
// Non-conflict error - reset task status
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
} else {
|
||||
// Non-conflict error - reset task status
|
||||
try {
|
||||
await store.updateTask(taskId, { status: null });
|
||||
} catch { /* best-effort */ }
|
||||
@@ -341,9 +480,15 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
});
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const prMonitor = new PrMonitor();
|
||||
const prCommentHandler = new PrCommentHandler(store);
|
||||
prMonitor.onNewComments((taskId, prInfo, comments) =>
|
||||
prCommentHandler.handleNewComments(taskId, prInfo, comments),
|
||||
);
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
semaphore,
|
||||
prMonitor,
|
||||
onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`),
|
||||
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user