diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 4b469a4..47a3caa 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -5,8 +5,53 @@ on: branches: [main] jobs: + lint: + name: Lint (TypeScript) + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run lint + + test: + name: Test (Vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + build: name: Build Dashboard + needs: [lint, test] runs-on: ubuntu-latest defaults: run: diff --git a/README.md b/README.md index d555488..47ffa28 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ # Dev Pilot +[![PR Gate](https://github.com/frankliu20/dev-pilot/actions/workflows/pr-gate.yml/badge.svg)](https://github.com/frankliu20/dev-pilot/actions/workflows/pr-gate.yml) + > An AI Multi-Agent engineering team that empowers **single developer = full engineering team** Automate the entire software development lifecycle: coding, testing, building, CI fixing, code review repair & comment resolution. diff --git a/README.zh-CN.md b/README.zh-CN.md index 642b305..be35c26 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,6 +2,8 @@ # Dev Pilot +[![PR Gate](https://github.com/frankliu20/dev-pilot/actions/workflows/pr-gate.yml/badge.svg)](https://github.com/frankliu20/dev-pilot/actions/workflows/pr-gate.yml) + > ⚠️ **状态:实验性** — 本项目处于早期 alpha 阶段。API、命令和配置格式可能随时变更。 **你的私人 AI 工程师团队。** 给它一个 GitHub Issue — 它自动分析、编码、测试,然后提交 PR。你只需审查和合并。 diff --git a/dashboard/__tests__/api/cleanup.test.ts b/dashboard/__tests__/api/cleanup.test.ts index ad4030e..53b6f51 100644 --- a/dashboard/__tests__/api/cleanup.test.ts +++ b/dashboard/__tests__/api/cleanup.test.ts @@ -10,7 +10,7 @@ vi.mock('child_process', () => ({ exec: vi.fn(), })); -vi.mock('../../../lib/config', () => ({ +vi.mock('@/lib/config', () => ({ getWorkspace: vi.fn().mockReturnValue('/workspace'), getConfig: vi.fn().mockReturnValue({ workspace: '/workspace', @@ -86,6 +86,10 @@ describe('POST /api/cleanup', () => { }); it('includes pull message with repo count', async () => { + // Reset mocks from previous tests + vi.mocked(existsSync).mockReset(); + vi.mocked(readdirSync).mockReset(); + vi.mocked(existsSync).mockImplementation((p: unknown) => { const path = String(p).replace(/\\/g, '/'); if (path.includes('/workspace') && !path.includes('.git')) return true; diff --git a/dashboard/__tests__/api/skills.test.ts b/dashboard/__tests__/api/skills.test.ts index 357a2f7..ef7f04e 100644 --- a/dashboard/__tests__/api/skills.test.ts +++ b/dashboard/__tests__/api/skills.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { NextRequest } from 'next/server'; vi.mock('fs/promises', () => ({ readdir: vi.fn(), @@ -12,6 +13,10 @@ vi.mock('os', () => ({ import { GET } from '@/app/api/skills/route'; import { readdir, readFile } from 'fs/promises'; +function createRequest(): NextRequest { + return new NextRequest(new URL('/api/skills', 'http://localhost:3000')); +} + describe('GET /api/skills', () => { it('returns categorized entries', async () => { // Mock readdir to return different items per directory @@ -37,7 +42,7 @@ describe('GET /api/skills', () => { vi.mocked(readFile).mockResolvedValue('# Some content'); - const res = await GET(); + const res = await GET(createRequest()); expect(res.status).toBe(200); const body = await res.json(); @@ -52,7 +57,7 @@ describe('GET /api/skills', () => { it('handles missing directories gracefully', async () => { vi.mocked(readdir).mockRejectedValue(new Error('ENOENT')); - const res = await GET(); + const res = await GET(createRequest()); expect(res.status).toBe(200); const body = await res.json(); @@ -72,7 +77,7 @@ describe('GET /api/skills', () => { vi.mocked(readFile).mockResolvedValue('# Test Skill Content'); - const res = await GET(); + const res = await GET(createRequest()); const body = await res.json(); const skillEntry = body.entries.find((e: { category: string }) => e.category === 'skill'); @@ -100,7 +105,7 @@ describe('GET /api/skills', () => { vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); - const res = await GET(); + const res = await GET(createRequest()); const body = await res.json(); expect(body.entries).toHaveLength(0); @@ -129,7 +134,7 @@ describe('GET /api/skills', () => { vi.mocked(readFile).mockResolvedValue('content'); - const res = await GET(); + const res = await GET(createRequest()); const body = await res.json(); // Order should be commands, agents, skills (as per source: [...commands, ...agents, ...skills]) diff --git a/dashboard/__tests__/api/tasks-assign.test.ts b/dashboard/__tests__/api/tasks-assign.test.ts index f0497a9..2d707da 100644 --- a/dashboard/__tests__/api/tasks-assign.test.ts +++ b/dashboard/__tests__/api/tasks-assign.test.ts @@ -103,7 +103,6 @@ describe('POST /api/tasks/assign', () => { 'https://github.com/owner/repo/issues/42', 'normal', true, - undefined, 'claude', ); }); @@ -125,7 +124,6 @@ describe('POST /api/tasks/assign', () => { 'https://github.com/owner/repo/issues/10', 'auto', undefined, - undefined, 'copilot', ); }); diff --git a/dashboard/__tests__/lib/config.test.ts b/dashboard/__tests__/lib/config.test.ts index 0ba41c7..bb4010d 100644 --- a/dashboard/__tests__/lib/config.test.ts +++ b/dashboard/__tests__/lib/config.test.ts @@ -44,7 +44,7 @@ describe('config', () => { const { getConfig } = await importConfig(); const config = getConfig(); - expect(config.workspace).toBe(join('/home/testuser', 'claude', 'workspace')); + expect(config.workspace).toBe(join('/home/testuser', 'claude', 'workdir')); expect(config.repos).toEqual([]); expect(config.skills).toEqual([]); }); @@ -85,7 +85,7 @@ describe('config', () => { const config = getConfig(); expect(consoleSpy).toHaveBeenCalled(); - expect(config.workspace).toBe(join('/home/testuser', 'claude', 'workspace')); + expect(config.workspace).toBe(join('/home/testuser', 'claude', 'workdir')); }); it('expands tilde in workspace path', async () => { diff --git a/dashboard/__tests__/lib/decisions.test.ts b/dashboard/__tests__/lib/decisions.test.ts index a664b75..72e0f8e 100644 --- a/dashboard/__tests__/lib/decisions.test.ts +++ b/dashboard/__tests__/lib/decisions.test.ts @@ -12,6 +12,7 @@ vi.mock('@/lib/statusLog', () => ({ import { readPendingDecisions, dismissDecision, watchDecisions } from '@/lib/decisions'; import { deriveTasks } from '@/lib/statusLog'; +import { makeStatusLogEntry } from '../helpers/factories'; beforeEach(() => { vi.mocked(fs.existsSync).mockReturnValue(true); @@ -58,6 +59,7 @@ describe('readPendingDecisions', () => { const task = makeTask({ taskId: 'issue-42', lastUpdate: '2026-04-08T10:00:00Z', // newer than decision + events: [makeStatusLogEntry({ timestamp: '2026-04-08T10:00:00Z', task_id: 'issue-42', type: 'task_start' })], }); vi.mocked(deriveTasks).mockReturnValue([task]); @@ -118,6 +120,7 @@ describe('readPendingDecisions', () => { const task = makeTask({ taskId: 'issue-42', lastUpdate: '2026-04-08T10:00:00Z', + events: [makeStatusLogEntry({ timestamp: '2026-04-08T10:00:00Z', task_id: 'issue-42', type: 'task_start' })], }); vi.mocked(deriveTasks).mockReturnValue([task]); @@ -155,6 +158,12 @@ describe('dismissDecision', () => { const decision = makeDecision({ taskId: 'issue-99' }); vi.mocked(fs.readdirSync).mockReturnValue(['issue-99.json'] as unknown as ReturnType); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(decision)); + vi.mocked(fs.existsSync).mockImplementation((p: unknown) => { + const path = String(p); + // Return true for decisions dir, false for JSONL log files + if (path.endsWith('.jsonl')) return false; + return true; + }); const result = dismissDecision('issue-42'); expect(result).toBe(false); @@ -162,6 +171,11 @@ describe('dismissDecision', () => { it('returns false when no .json files exist', () => { vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType); + vi.mocked(fs.existsSync).mockImplementation((p: unknown) => { + const path = String(p); + if (path.endsWith('.jsonl')) return false; + return true; + }); expect(dismissDecision('issue-42')).toBe(false); }); @@ -180,8 +194,9 @@ describe('dismissDecision', () => { describe('watchDecisions', () => { it('creates directory if missing and watches for .json changes', () => { + vi.useFakeTimers(); vi.mocked(fs.existsSync).mockReturnValue(false); - const mockWatcher = { close: vi.fn() }; + const mockWatcher = { close: vi.fn(), on: vi.fn() }; vi.mocked(fs.watch).mockImplementation((_dir: any, listener: any) => { listener('change', 'task.json'); return mockWatcher as any; @@ -191,15 +206,17 @@ describe('watchDecisions', () => { const unwatch = watchDecisions(callback); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + vi.advanceTimersByTime(300); expect(callback).toHaveBeenCalledOnce(); unwatch(); expect(mockWatcher.close).toHaveBeenCalled(); + vi.useRealTimers(); }); it('does not create directory if it exists', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - const mockWatcher = { close: vi.fn() }; + const mockWatcher = { close: vi.fn(), on: vi.fn() }; vi.mocked(fs.watch).mockReturnValue(mockWatcher as any); watchDecisions(vi.fn()); @@ -207,7 +224,8 @@ describe('watchDecisions', () => { }); it('ignores non-.json file changes', () => { - const mockWatcher = { close: vi.fn() }; + vi.useFakeTimers(); + const mockWatcher = { close: vi.fn(), on: vi.fn() }; vi.mocked(fs.watch).mockImplementation((_dir: any, listener: any) => { listener('change', 'file.txt'); return mockWatcher as any; @@ -215,6 +233,8 @@ describe('watchDecisions', () => { const callback = vi.fn(); watchDecisions(callback); + vi.advanceTimersByTime(300); expect(callback).not.toHaveBeenCalled(); + vi.useRealTimers(); }); }); diff --git a/dashboard/__tests__/lib/statusLog.test.ts b/dashboard/__tests__/lib/statusLog.test.ts index fd9ec45..66e06b5 100644 --- a/dashboard/__tests__/lib/statusLog.test.ts +++ b/dashboard/__tests__/lib/statusLog.test.ts @@ -256,6 +256,7 @@ describe('deriveTasks', () => { describe('watchStatusLog', () => { it('calls callback when a .jsonl file changes', () => { + vi.useFakeTimers(); const callback = vi.fn(); const mockWatcher = { close: vi.fn() }; vi.mocked(fs.watch).mockImplementation((_dir: any, listener: any) => { @@ -265,10 +266,12 @@ describe('watchStatusLog', () => { }); const unwatch = watchStatusLog(callback); + vi.advanceTimersByTime(300); expect(callback).toHaveBeenCalledOnce(); unwatch(); expect(mockWatcher.close).toHaveBeenCalled(); + vi.useRealTimers(); }); it('does not call callback for non-.jsonl files', () => { diff --git a/dashboard/package.json b/dashboard/package.json index 35f8c78..efc6fc4 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,7 +8,8 @@ "start": "next start", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "lint": "tsc --noEmit --project tsconfig.lint.json" }, "dependencies": { "js-yaml": "^4.1.1", diff --git a/dashboard/tsconfig.lint.json b/dashboard/tsconfig.lint.json new file mode 100644 index 0000000..5119bf9 --- /dev/null +++ b/dashboard/tsconfig.lint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "__tests__"] +}