feat(enrichment): WP-6 hooks for label sync, dependencies, and metrics data
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cb6594bc0c
commit
35aaf892ec
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useDependencies } from '../useDependencies';
|
||||
import { usePhase4Store } from '../../../../stores/github/phase4-store';
|
||||
|
||||
// Mock project store
|
||||
vi.mock('../../../../stores/project-store', () => ({
|
||||
useProjectStore: vi.fn((selector: (s: { activeProject: { id: string } | null }) => unknown) =>
|
||||
selector({ activeProject: { id: 'test-project' } }),
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock window.electronAPI on the jsdom window
|
||||
const mockGithub = {
|
||||
fetchDependencies: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(window as Record<string, unknown>).electronAPI = { github: mockGithub };
|
||||
});
|
||||
|
||||
describe('useDependencies', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
usePhase4Store.getState().clearDependencies();
|
||||
});
|
||||
|
||||
it('returns empty dependencies for null issue', () => {
|
||||
const { result } = renderHook(() => useDependencies(null));
|
||||
expect(result.current.dependencies.tracks).toEqual([]);
|
||||
expect(result.current.dependencies.trackedBy).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-fetches dependencies when issue number provided', async () => {
|
||||
mockGithub.fetchDependencies.mockResolvedValue({
|
||||
tracks: [{ issueNumber: 10, title: 'Sub-task', state: 'open' }],
|
||||
trackedBy: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDependencies(42));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dependencies.tracks).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(mockGithub.fetchDependencies).toHaveBeenCalledWith('test-project', 42);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
mockGithub.fetchDependencies.mockResolvedValue({
|
||||
error: 'GraphQL unavailable',
|
||||
tracks: [],
|
||||
trackedBy: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDependencies(42));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('GraphQL unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles fetch exception', async () => {
|
||||
mockGithub.fetchDependencies.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useDependencies(42));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
it('refetch reloads dependencies', async () => {
|
||||
mockGithub.fetchDependencies.mockResolvedValue({ tracks: [], trackedBy: [] });
|
||||
|
||||
const { result } = renderHook(() => useDependencies(42));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGithub.fetchDependencies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
|
||||
expect(mockGithub.fetchDependencies).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useLabelSync } from '../useLabelSync';
|
||||
import { useLabelSyncStore } from '../../../../stores/github/label-sync-store';
|
||||
|
||||
// Mock project store
|
||||
vi.mock('../../../../stores/project-store', () => ({
|
||||
useProjectStore: vi.fn((selector: (s: { activeProject: { id: string } | null }) => unknown) =>
|
||||
selector({ activeProject: { id: 'test-project' } }),
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock window.electronAPI on jsdom window
|
||||
const mockGithub = {
|
||||
getLabelSyncStatus: vi.fn(),
|
||||
enableLabelSync: vi.fn(),
|
||||
disableLabelSync: vi.fn(),
|
||||
syncIssueLabel: vi.fn(),
|
||||
saveLabelSyncConfig: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(window as Record<string, unknown>).electronAPI = { github: mockGithub };
|
||||
});
|
||||
|
||||
describe('useLabelSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useLabelSyncStore.getState().reset();
|
||||
});
|
||||
|
||||
it('returns initial state', () => {
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
expect(result.current.config.enabled).toBe(false);
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
expect(result.current.isSyncing).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('loadStatus fetches config from API', async () => {
|
||||
mockGithub.getLabelSyncStatus.mockResolvedValue({
|
||||
enabled: true,
|
||||
lastSyncedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.loadStatus();
|
||||
});
|
||||
|
||||
expect(result.current.config.enabled).toBe(true);
|
||||
expect(result.current.isLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it('enableSync calls API and updates store', async () => {
|
||||
mockGithub.enableLabelSync.mockResolvedValue({
|
||||
created: 7, updated: 0, removed: 0, errors: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.enableSync();
|
||||
});
|
||||
|
||||
expect(mockGithub.enableLabelSync).toHaveBeenCalledWith('test-project');
|
||||
expect(result.current.config.enabled).toBe(true);
|
||||
expect(result.current.lastResult?.created).toBe(7);
|
||||
});
|
||||
|
||||
it('disableSync calls API with cleanup flag', async () => {
|
||||
mockGithub.disableLabelSync.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.disableSync(true);
|
||||
});
|
||||
|
||||
expect(mockGithub.disableLabelSync).toHaveBeenCalledWith('test-project', true);
|
||||
expect(result.current.config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('syncIssueLabel calls API when sync is enabled', async () => {
|
||||
mockGithub.syncIssueLabel.mockResolvedValue({ synced: true });
|
||||
// Enable sync first
|
||||
useLabelSyncStore.getState().setConfig({ enabled: true, lastSyncedAt: 'now' });
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.syncIssueLabel(42, 'triage', 'new');
|
||||
});
|
||||
|
||||
expect(mockGithub.syncIssueLabel).toHaveBeenCalledWith('test-project', 42, 'triage', 'new');
|
||||
});
|
||||
|
||||
it('syncIssueLabel skips when sync is disabled', async () => {
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.syncIssueLabel(42, 'triage', 'new');
|
||||
});
|
||||
|
||||
expect(mockGithub.syncIssueLabel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saveConfig persists config', async () => {
|
||||
mockGithub.saveLabelSyncConfig.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
const config = { enabled: true, lastSyncedAt: '2026-01-01T00:00:00Z' };
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(config);
|
||||
});
|
||||
|
||||
expect(mockGithub.saveLabelSyncConfig).toHaveBeenCalledWith('test-project', config);
|
||||
expect(result.current.config.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('handles enable error gracefully', async () => {
|
||||
mockGithub.enableLabelSync.mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const { result } = renderHook(() => useLabelSync());
|
||||
await act(async () => {
|
||||
await result.current.enableSync();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Auth failed');
|
||||
expect(result.current.isSyncing).toBe(false);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMetrics } from '../useMetrics';
|
||||
import { usePhase4Store } from '../../../../stores/github/phase4-store';
|
||||
import { createEmptyMetrics } from '../../../../../shared/types/metrics';
|
||||
|
||||
// Mock project store
|
||||
vi.mock('../../../../stores/project-store', () => ({
|
||||
useProjectStore: vi.fn((selector: (s: { activeProject: { id: string } | null }) => unknown) =>
|
||||
selector({ activeProject: { id: 'test-project' } }),
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock window.electronAPI on jsdom window
|
||||
const mockGithub = {
|
||||
computeMetrics: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(window as Record<string, unknown>).electronAPI = { github: mockGithub };
|
||||
});
|
||||
|
||||
describe('useMetrics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
usePhase4Store.getState().setMetrics(createEmptyMetrics());
|
||||
usePhase4Store.getState().setMetricsError(null);
|
||||
usePhase4Store.getState().setMetricsTimeWindow('30d');
|
||||
});
|
||||
|
||||
it('returns initial state', () => {
|
||||
const { result } = renderHook(() => useMetrics());
|
||||
expect(result.current.metrics.totalTransitions).toBe(0);
|
||||
expect(result.current.timeWindow).toBe('30d');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('computeMetrics fetches from API', async () => {
|
||||
const mockMetrics = {
|
||||
...createEmptyMetrics(),
|
||||
totalTransitions: 42,
|
||||
computedAt: '2026-02-12T00:00:00Z',
|
||||
};
|
||||
mockGithub.computeMetrics.mockResolvedValue(mockMetrics);
|
||||
|
||||
const { result } = renderHook(() => useMetrics());
|
||||
await act(async () => {
|
||||
await result.current.computeMetrics();
|
||||
});
|
||||
|
||||
expect(mockGithub.computeMetrics).toHaveBeenCalledWith('test-project', '30d');
|
||||
expect(result.current.metrics.totalTransitions).toBe(42);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('computeMetrics with override window', async () => {
|
||||
mockGithub.computeMetrics.mockResolvedValue(createEmptyMetrics());
|
||||
|
||||
const { result } = renderHook(() => useMetrics());
|
||||
await act(async () => {
|
||||
await result.current.computeMetrics('7d');
|
||||
});
|
||||
|
||||
expect(mockGithub.computeMetrics).toHaveBeenCalledWith('test-project', '7d');
|
||||
});
|
||||
|
||||
it('handles compute error', async () => {
|
||||
mockGithub.computeMetrics.mockRejectedValue(new Error('Timeout'));
|
||||
|
||||
const { result } = renderHook(() => useMetrics());
|
||||
await act(async () => {
|
||||
await result.current.computeMetrics();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Timeout');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('setTimeWindow updates window and auto-recomputes', async () => {
|
||||
mockGithub.computeMetrics.mockResolvedValue(createEmptyMetrics());
|
||||
|
||||
const { result } = renderHook(() => useMetrics());
|
||||
act(() => {
|
||||
result.current.setTimeWindow('all');
|
||||
});
|
||||
|
||||
expect(usePhase4Store.getState().metricsTimeWindow).toBe('all');
|
||||
expect(mockGithub.computeMetrics).toHaveBeenCalledWith('test-project', 'all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { usePhase4Store } from '../../../stores/github/phase4-store';
|
||||
import type { IssueDependencies } from '../../../../shared/types/dependencies';
|
||||
|
||||
const EMPTY_DEPS: IssueDependencies = { tracks: [], trackedBy: [] };
|
||||
|
||||
export function useDependencies(issueNumber: number | null) {
|
||||
const projectId = useProjectStore((s) => s.activeProject?.id ?? null);
|
||||
const depsMap = usePhase4Store((s) => s.dependencies);
|
||||
const loadingMap = usePhase4Store((s) => s.dependencyLoading);
|
||||
const errorsMap = usePhase4Store((s) => s.dependencyErrors);
|
||||
|
||||
const dependencies = useMemo(
|
||||
() => (issueNumber ? depsMap[issueNumber] ?? EMPTY_DEPS : EMPTY_DEPS),
|
||||
[issueNumber, depsMap],
|
||||
);
|
||||
|
||||
const isLoading = issueNumber ? loadingMap[issueNumber] ?? false : false;
|
||||
const error = issueNumber ? errorsMap[issueNumber] ?? null : null;
|
||||
|
||||
const fetchDependencies = useCallback(async () => {
|
||||
if (!projectId || !issueNumber) return;
|
||||
|
||||
usePhase4Store.getState().setDependencyLoading(issueNumber, true);
|
||||
try {
|
||||
const result = await window.electronAPI.github.fetchDependencies(projectId, issueNumber);
|
||||
if (result.error) {
|
||||
usePhase4Store.getState().setDependencyError(issueNumber, result.error);
|
||||
} else {
|
||||
usePhase4Store.getState().setDependencies(issueNumber, {
|
||||
tracks: result.tracks,
|
||||
trackedBy: result.trackedBy,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
usePhase4Store.getState().setDependencyError(
|
||||
issueNumber,
|
||||
err instanceof Error ? err.message : 'Failed to fetch dependencies',
|
||||
);
|
||||
}
|
||||
}, [projectId, issueNumber]);
|
||||
|
||||
// Auto-fetch when issue changes
|
||||
useEffect(() => {
|
||||
if (issueNumber && projectId) {
|
||||
fetchDependencies();
|
||||
}
|
||||
}, [issueNumber, projectId, fetchDependencies]);
|
||||
|
||||
return {
|
||||
dependencies,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchDependencies,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useLabelSyncStore } from '../../../stores/github/label-sync-store';
|
||||
import type { LabelSyncConfig } from '../../../../shared/types/label-sync';
|
||||
|
||||
export function useLabelSync() {
|
||||
const projectId = useProjectStore((s) => s.activeProject?.id ?? null);
|
||||
const store = useLabelSyncStore();
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const config = await window.electronAPI.github.getLabelSyncStatus(projectId);
|
||||
store.setConfig(config);
|
||||
} catch (error) {
|
||||
store.setError(error instanceof Error ? error.message : 'Failed to load label sync status');
|
||||
}
|
||||
}, [projectId, store]);
|
||||
|
||||
const enableSync = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
store.setSyncing(true);
|
||||
try {
|
||||
const result = await window.electronAPI.github.enableLabelSync(projectId);
|
||||
store.setLastResult(result);
|
||||
store.setConfig({ enabled: true, lastSyncedAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
store.setError(error instanceof Error ? error.message : 'Failed to enable label sync');
|
||||
} finally {
|
||||
store.setSyncing(false);
|
||||
}
|
||||
}, [projectId, store]);
|
||||
|
||||
const disableSync = useCallback(async (cleanup: boolean) => {
|
||||
if (!projectId) return;
|
||||
store.setSyncing(true);
|
||||
try {
|
||||
await window.electronAPI.github.disableLabelSync(projectId, cleanup);
|
||||
store.setConfig({ enabled: false, lastSyncedAt: null });
|
||||
} catch (error) {
|
||||
store.setError(error instanceof Error ? error.message : 'Failed to disable label sync');
|
||||
} finally {
|
||||
store.setSyncing(false);
|
||||
}
|
||||
}, [projectId, store]);
|
||||
|
||||
const syncIssueLabel = useCallback(async (
|
||||
issueNumber: number,
|
||||
newState: string,
|
||||
oldState: string | null,
|
||||
) => {
|
||||
if (!projectId || !store.config.enabled) return;
|
||||
try {
|
||||
await window.electronAPI.github.syncIssueLabel(projectId, issueNumber, newState, oldState);
|
||||
} catch {
|
||||
// Non-blocking — label sync failures shouldn't disrupt workflow
|
||||
}
|
||||
}, [projectId, store.config.enabled]);
|
||||
|
||||
const saveConfig = useCallback(async (config: LabelSyncConfig) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await window.electronAPI.github.saveLabelSyncConfig(projectId, config);
|
||||
store.setConfig(config);
|
||||
} catch (error) {
|
||||
store.setError(error instanceof Error ? error.message : 'Failed to save config');
|
||||
}
|
||||
}, [projectId, store]);
|
||||
|
||||
return {
|
||||
config: store.config,
|
||||
isLoaded: store.isLoaded,
|
||||
isSyncing: store.isSyncing,
|
||||
error: store.error,
|
||||
lastResult: store.lastResult,
|
||||
loadStatus,
|
||||
enableSync,
|
||||
disableSync,
|
||||
syncIssueLabel,
|
||||
saveConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { usePhase4Store } from '../../../stores/github/phase4-store';
|
||||
import type { MetricsTimeWindow } from '../../../../shared/types/metrics';
|
||||
|
||||
export function useMetrics() {
|
||||
const projectId = useProjectStore((s) => s.activeProject?.id ?? null);
|
||||
const metrics = usePhase4Store((s) => s.metrics);
|
||||
const timeWindow = usePhase4Store((s) => s.metricsTimeWindow);
|
||||
const isLoading = usePhase4Store((s) => s.metricsLoading);
|
||||
const error = usePhase4Store((s) => s.metricsError);
|
||||
|
||||
const store = usePhase4Store;
|
||||
|
||||
const computeMetrics = useCallback(async (overrideWindow?: MetricsTimeWindow) => {
|
||||
if (!projectId) return;
|
||||
|
||||
const tw = overrideWindow ?? timeWindow;
|
||||
if (overrideWindow) {
|
||||
store.getState().setMetricsTimeWindow(tw);
|
||||
}
|
||||
|
||||
store.getState().setMetricsLoading(true);
|
||||
try {
|
||||
const result = await globalThis.window.electronAPI.github.computeMetrics(projectId, tw);
|
||||
store.getState().setMetrics(result);
|
||||
} catch (err) {
|
||||
store.getState().setMetricsError(
|
||||
err instanceof Error ? err.message : 'Failed to compute metrics',
|
||||
);
|
||||
}
|
||||
}, [projectId, timeWindow]);
|
||||
|
||||
const setTimeWindow = useCallback((tw: MetricsTimeWindow) => {
|
||||
store.getState().setMetricsTimeWindow(tw);
|
||||
// Auto-recompute on window change
|
||||
if (projectId) {
|
||||
store.getState().setMetricsLoading(true);
|
||||
globalThis.window.electronAPI.github.computeMetrics(projectId, tw)
|
||||
.then((result) => store.getState().setMetrics(result))
|
||||
.catch((err: unknown) => store.getState().setMetricsError(
|
||||
err instanceof Error ? err.message : 'Failed to compute metrics',
|
||||
));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
timeWindow,
|
||||
isLoading,
|
||||
error,
|
||||
computeMetrics,
|
||||
setTimeWindow,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user