diff --git a/src/components/kanban-board.test.tsx b/src/components/kanban-board.test.tsx index 070eaf3..4a7eaef 100644 --- a/src/components/kanban-board.test.tsx +++ b/src/components/kanban-board.test.tsx @@ -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 }, +})); + vi.mock("@dnd-kit/core", () => ({ - DndContext: ({ children }: { children: React.ReactNode }) =>
{children}
, + DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: (event: { active: { id: string }; over: { id: string } | null }) => Promise }) => { + dnd.latestProps = { onDragEnd }; + return
{children}
; + }, DragOverlay: ({ children }: { children: React.ReactNode }) =>
{children}
, KeyboardSensor: vi.fn(), PointerSensor: vi.fn(), @@ -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(); + 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(); + 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(); + 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(); + 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 | undefined; + expect(headers).not.toHaveProperty("Authorization", expect.stringContaining("Bearer")); + }); }); diff --git a/src/components/kanban-board.tsx b/src/components/kanban-board.tsx index 33fc7bd..b61168c 100644 --- a/src/components/kanban-board.tsx +++ b/src/components/kanban-board.tsx @@ -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[]; @@ -54,6 +58,8 @@ export const KanbanBoard = forwardRef(function const [refreshing, setRefreshing] = useState(false); const [refreshError, setRefreshError] = useState(null); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + const pendingSyncReposRef = useRef>(new Set()); + const syncTimerRef = useRef | null>(null); // Store latest issues in a ref for doRefresh to always use current state const issuesRef = useRef(issues); @@ -103,6 +109,47 @@ export const KanbanBoard = forwardRef(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 () => { @@ -188,7 +235,8 @@ export const KanbanBoard = forwardRef(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) =>