feat(offload): modularize playbooks with TaskRunner and integrate agentic fix-pr loop

This commit is contained in:
mkorwel
2026-03-13 19:31:32 -07:00
parent 3649059a28
commit 8692b347f0
11 changed files with 458 additions and 304 deletions
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import fs from 'fs';
import { runFixPlaybook } from '../../scripts/playbooks/fix.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Fix Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); })
} as any;
});
});
it('should register and run initial build, failure analysis, and fixer', async () => {
runFixPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
const spawnCalls = vi.mocked(spawn).mock.calls;
expect(spawnCalls.some(c => c[0].includes('npm ci'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('gh run view --log-failed'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('Gemini Fixer'))).toBe(false); // Should wait for build
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import fs from 'fs';
import { runReadyPlaybook } from '../../scripts/playbooks/ready.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Ready Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); })
} as any;
});
});
it('should register and run clean, preflight, and conflict checks', async () => {
runReadyPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
const spawnCalls = vi.mocked(spawn).mock.calls;
expect(spawnCalls.some(c => c[0].includes('npm run clean'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('git fetch origin main'))).toBe(true);
});
});
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import fs from 'fs';
import { runReviewPlaybook } from '../../scripts/playbooks/review.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Review Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); })
} as any;
});
});
it('should register and run build, ci, analysis, and verification', async () => {
const promise = runReviewPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
// The worker uses setInterval(1500) to check for completion, so we need to wait
// or mock the timer. For simplicity in this POC, we'll just verify spawn calls.
const spawnCalls = vi.mocked(spawn).mock.calls;
// These should start immediately (no deps)
expect(spawnCalls.some(c => c[0].includes('npm ci'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('gh pr checks'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('/review-frontend'))).toBe(true);
});
});