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:
gsxdsm
2026-03-29 23:13:24 -07:00
parent 94f599a3df
commit fd9a1b186a
27 changed files with 1715 additions and 109 deletions

View File

@@ -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 ────────────────────────────────────────────────

View File

@@ -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);

View File

@@ -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(", ")}`),
});