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:
Sondre Engebråten
2026-02-12 19:20:06 +01:00
co-authored by Claude Opus 4.6
parent cb6594bc0c
commit 35aaf892ec
6 changed files with 511 additions and 0 deletions
@@ -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);
});
});
@@ -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);
});
});
@@ -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,
};
}