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
168 changes: 166 additions & 2 deletions src/components/kanban-board.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Issue } from "@/types";
import { KanbanBoard } from "./kanban-board";

const dnd = vi.hoisted(() => ({
latestProps: null as null | { onDragEnd: (event: { active: { id: string }; over: { id: string } | null }) => Promise<void> },
}));

vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: (event: { active: { id: string }; over: { id: string } | null }) => Promise<void> }) => {
dnd.latestProps = { onDragEnd };
return <div>{children}</div>;
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
Expand Down Expand Up @@ -132,4 +139,161 @@ describe("KanbanBoard refresh status", () => {
expect(screen.queryByText("Board refresh failed. Showing previous state.")).not.toBeInTheDocument()
);
});

it("refreshes after a card move and debounces repo-scoped GitHub sync", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([issue({ labels: ["status/in-progress"] })]),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ syncedCount: 1 }) });
vi.stubGlobal("fetch", fetchMock);

render(<KanbanBoard initialIssues={[issue()]} />);
await screen.findByText("Existing issue");

await act(async () => {
await dnd.latestProps?.onDragEnd({
active: { id: "issue-1" },
over: { id: "status/in-progress" },
});
});

await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/issues", expect.any(Object)));
expect(fetchMock).not.toHaveBeenCalledWith("/api/sync", expect.any(Object));

await act(async () => {
vi.advanceTimersByTime(10_000);
});

await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ repoFullName: "misospace/dispatch" }),
})
)
);
});

it("batches multiple moves in one debounce window into one sync per repo", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([issue({ labels: ["status/in-progress"] })]),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([issue({ labels: ["status/in-review"] })]),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ syncedCount: 1 }) });
vi.stubGlobal("fetch", fetchMock);

render(<KanbanBoard initialIssues={[issue()]} />);
await screen.findByText("Existing issue");

await act(async () => {
await dnd.latestProps?.onDragEnd({
active: { id: "issue-1" },
over: { id: "status/in-progress" },
});
});
expect(fetchMock).toHaveBeenCalledTimes(2);

await act(async () => {
vi.advanceTimersByTime(5_000);
await dnd.latestProps?.onDragEnd({
active: { id: "issue-1" },
over: { id: "status/in-review" },
});
});
expect(fetchMock).toHaveBeenCalledTimes(4);

await act(async () => {
vi.advanceTimersByTime(9_999);
});
expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sync")).toHaveLength(0);

await act(async () => {
vi.advanceTimersByTime(1);
});

await waitFor(() => expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sync")).toHaveLength(1));
});

it("shows a warning when debounced GitHub sync fails", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([issue({ labels: ["status/in-progress"] })]),
})
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "nope" }) });
vi.stubGlobal("fetch", fetchMock);

render(<KanbanBoard initialIssues={[issue()]} />);
await screen.findByText("Existing issue");

await act(async () => {
await dnd.latestProps?.onDragEnd({
active: { id: "issue-1" },
over: { id: "status/in-progress" },
});
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));

await act(async () => {
vi.advanceTimersByTime(10_000);
});

expect(await screen.findByText("GitHub sync failed. Board changes were saved; try Sync Issues or refresh later.")).toBeInTheDocument();
});

it("does not expose agent token in the debounced GitHub sync request", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([issue({ labels: ["status/in-progress"] })]),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ syncedCount: 1 }) });
vi.stubGlobal("fetch", fetchMock);

render(<KanbanBoard initialIssues={[issue()]} />);
await screen.findByText("Existing issue");

await act(async () => {
await dnd.latestProps?.onDragEnd({
active: { id: "issue-1" },
over: { id: "status/in-progress" },
});
});

await act(async () => {
vi.advanceTimersByTime(10_000);
});

await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ repoFullName: "misospace/dispatch" }),
})
)
);

const syncCall = fetchMock.mock.calls.find(([url]) => url === "/api/sync");
expect(syncCall).toBeDefined();
const headers = (syncCall![1] as RequestInit).headers as Record<string, string> | undefined;
expect(headers).not.toHaveProperty("Authorization", expect.stringContaining("Bearer"));
});
});
50 changes: 49 additions & 1 deletion src/components/kanban-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const COLUMNS: { id: StatusLabel; title: string }[] = [
];

const AUTO_REFRESH_INTERVAL_MS = 30_000; // 30 seconds
// 10s debounce balances responsiveness with rate-limiting: long enough to batch
// rapid drag-and-drop moves on the same repo into a single sync, short enough
// that stale board state is reconciled with GitHub within a typical user session.
const MOVE_SYNC_DEBOUNCE_MS = 10_000;

interface KanbanBoardProps {
initialIssues: Issue[];
Expand All @@ -54,6 +58,8 @@ export const KanbanBoard = forwardRef<KanbanBoardRef, KanbanBoardProps>(function
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const pendingSyncReposRef = useRef<Set<string>>(new Set());
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Store latest issues in a ref for doRefresh to always use current state
const issuesRef = useRef(issues);
Expand Down Expand Up @@ -103,6 +109,47 @@ export const KanbanBoard = forwardRef<KanbanBoardRef, KanbanBoardProps>(function
}
}, []);

const scheduleSync = useCallback((repoFullName: string) => {
pendingSyncReposRef.current.add(repoFullName);

if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
}

syncTimerRef.current = setTimeout(() => {
const repos = Array.from(pendingSyncReposRef.current);
pendingSyncReposRef.current.clear();
syncTimerRef.current = null;

void Promise.all(
repos.map(async (repo) => {
const response = await authedFetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoFullName: repo }),
});

if (!response.ok) {
throw new Error(`Sync failed for ${repo}`);
}
})
).catch(() => {
setNotification({
type: "error",
message: "GitHub sync failed. Board changes were saved; try Sync Issues or refresh later.",
});
});
}, MOVE_SYNC_DEBOUNCE_MS);
}, []);

useEffect(() => {
return () => {
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
}
};
}, []);

// Auto-refresh every 30 seconds
useEffect(() => {
const intervalId = setInterval(async () => {
Expand Down Expand Up @@ -188,7 +235,8 @@ export const KanbanBoard = forwardRef<KanbanBoardRef, KanbanBoardProps>(function
throw new Error(data.error || "Failed to move issue");
}

await authedFetch("/api/sync", { method: "POST" });
await doRefresh();
scheduleSync(issue.repository.fullName);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to move issue");
setIssues((prev) =>
Expand Down