feat(HAI-005): add auto-merge functionality for in-review tasks
- Add autoMerge boolean to Settings model with default false - Add auto-merge toggle UI to the In Review column header - Implement server-side auto-merge behavior in dashboard command - Fix pre-existing build errors in types, executor, and dashboard server - Update README with auto-merge documentation
This commit is contained in:
@@ -24,6 +24,20 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
|
||||
});
|
||||
|
||||
// Auto-merge: when a task lands in "in-review" and autoMerge is enabled, merge it
|
||||
store.on("task:moved", async ({ task, to }) => {
|
||||
if (to !== "in-review") return;
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
if (!settings.autoMerge) return;
|
||||
console.log(`[auto-merge] Merging ${task.id}...`);
|
||||
await onMerge(task.id);
|
||||
console.log(`[auto-merge] ✓ ${task.id} merged`);
|
||||
} catch (err: any) {
|
||||
console.log(`[auto-merge] ✗ ${task.id}: ${err.message ?? err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the web server with AI merge wired in
|
||||
const app = createServer(store, { onMerge });
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface Settings {
|
||||
maxWorktrees: number;
|
||||
pollIntervalMs: number;
|
||||
groupOverlappingFiles: boolean;
|
||||
autoMerge: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -54,6 +55,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task } from "@hai/core";
|
||||
import { fetchConfig } from "./api";
|
||||
import { fetchConfig, fetchSettings, updateSettings } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
@@ -14,12 +14,16 @@ function AppInner() {
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [autoMerge, setAutoMerge] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
.then((cfg) => setMaxConcurrent(cfg.maxConcurrent))
|
||||
.catch(() => {/* keep default */});
|
||||
fetchSettings()
|
||||
.then((s) => setAutoMerge(!!s.autoMerge))
|
||||
.catch(() => {/* keep default */});
|
||||
}, []);
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
@@ -35,6 +39,16 @@ function AppInner() {
|
||||
[createTask],
|
||||
);
|
||||
|
||||
const handleToggleAutoMerge = useCallback(async () => {
|
||||
const next = !autoMerge;
|
||||
setAutoMerge(next);
|
||||
try {
|
||||
await updateSettings({ autoMerge: next });
|
||||
} catch {
|
||||
setAutoMerge(!next); // revert on failure
|
||||
}
|
||||
}, [autoMerge]);
|
||||
|
||||
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
}, []);
|
||||
@@ -54,6 +68,8 @@ function AppInner() {
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
/>
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -13,9 +13,11 @@ interface BoardProps {
|
||||
onCancelCreate: () => void;
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
onNewTask: () => void;
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge }: BoardProps) {
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
@@ -29,6 +31,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
|
||||
@@ -19,9 +19,11 @@ interface ColumnProps {
|
||||
onCancelCreate?: () => void;
|
||||
onCreateTask?: (input: TaskCreateInput) => Promise<Task>;
|
||||
onNewTask?: () => void;
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
@@ -62,6 +64,17 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
<div className={`column-dot dot-${column}`} />
|
||||
<h2>{COLUMN_LABELS[column]}</h2>
|
||||
<span className="column-count">{tasks.length}</span>
|
||||
{column === "in-review" && onToggleAutoMerge && (
|
||||
<label className="auto-merge-toggle" title={autoMerge ? "Auto-merge enabled" : "Auto-merge disabled"}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!autoMerge}
|
||||
onChange={onToggleAutoMerge}
|
||||
/>
|
||||
<span className="toggle-slider" />
|
||||
<span className="toggle-label">Auto-merge</span>
|
||||
</label>
|
||||
)}
|
||||
{onNewTask && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
|
||||
@@ -9,7 +9,7 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false });
|
||||
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -719,3 +719,53 @@ html, body {
|
||||
border-radius: var(--radius);
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
/* Auto-merge toggle */
|
||||
.auto-merge-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.auto-merge-toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--card);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.auto-merge-toggle input:checked + .toggle-slider {
|
||||
background: var(--in-review);
|
||||
}
|
||||
|
||||
.auto-merge-toggle input:checked + .toggle-slider::after {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ServerOptions {
|
||||
maxConcurrent?: number;
|
||||
}
|
||||
|
||||
export function createServer(store: TaskStore, options?: ServerOptions) {
|
||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export class TaskExecutor {
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const { step, type: reviewType, step_name, baseline } = params;
|
||||
const { step, type: reviewType, step_name, baseline } = params as { step: number; type: "plan" | "code"; step_name: string; baseline?: string };
|
||||
|
||||
console.log(
|
||||
`[reviewer] ${taskId}: ${reviewType} review for Step ${step} (${step_name})`,
|
||||
|
||||
Reference in New Issue
Block a user