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
45 changes: 45 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。你只需审查和合并。
Expand Down
6 changes: 5 additions & 1 deletion dashboard/__tests__/api/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 10 additions & 5 deletions dashboard/__tests__/api/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { NextRequest } from 'next/server';

vi.mock('fs/promises', () => ({
readdir: vi.fn(),
Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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])
Expand Down
2 changes: 0 additions & 2 deletions dashboard/__tests__/api/tasks-assign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ describe('POST /api/tasks/assign', () => {
'https://github.com/owner/repo/issues/42',
'normal',
true,
undefined,
'claude',
);
});
Expand All @@ -125,7 +124,6 @@ describe('POST /api/tasks/assign', () => {
'https://github.com/owner/repo/issues/10',
'auto',
undefined,
undefined,
'copilot',
);
});
Expand Down
4 changes: 2 additions & 2 deletions dashboard/__tests__/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 23 additions & 3 deletions dashboard/__tests__/lib/decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -155,13 +158,24 @@ describe('dismissDecision', () => {
const decision = makeDecision({ taskId: 'issue-99' });
vi.mocked(fs.readdirSync).mockReturnValue(['issue-99.json'] as unknown as ReturnType<typeof fs.readdirSync>);
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);
});

it('returns false when no .json files exist', () => {
vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
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);
});

Expand All @@ -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;
Expand All @@ -191,30 +206,35 @@ 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());
expect(fs.mkdirSync).not.toHaveBeenCalled();
});

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;
});

const callback = vi.fn();
watchDecisions(callback);
vi.advanceTimersByTime(300);
expect(callback).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
3 changes: 3 additions & 0 deletions dashboard/__tests__/lib/statusLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/tsconfig.lint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "__tests__"]
}
Loading