Merge branch 'cw/small-change-flow-task-3yaqNLqG3kk5ku6rH0exF' into cw-merge-1772810964768

This commit is contained in:
Lukas May
2026-03-06 16:29:25 +01:00
4 changed files with 187 additions and 7 deletions

View File

@@ -0,0 +1,161 @@
/**
* DrizzleErrandRepository Tests
*
* Tests for the Errand repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleErrandRepository } from './errand.js';
import { DrizzleProjectRepository } from './project.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
import type { Project } from '../../schema.js';
describe('DrizzleErrandRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleErrandRepository;
let projectRepo: DrizzleProjectRepository;
const createProject = async (): Promise<Project> => {
const suffix = Math.random().toString(36).slice(2, 8);
return projectRepo.create({
name: `test-project-${suffix}`,
url: `https://github.com/test/repo-${suffix}`,
});
};
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleErrandRepository(db);
projectRepo = new DrizzleProjectRepository(db);
});
describe('create', () => {
it('creates an errand with generated id and timestamps', async () => {
const project = await createProject();
const errand = await repo.create({
description: 'fix typo',
branch: 'cw/errand/fix-typo-abc12345',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
expect(errand.id).toBeDefined();
expect(errand.id.length).toBeGreaterThan(0);
expect(errand.description).toBe('fix typo');
expect(errand.branch).toBe('cw/errand/fix-typo-abc12345');
expect(errand.baseBranch).toBe('main');
expect(errand.agentId).toBeNull();
expect(errand.projectId).toBe(project.id);
expect(errand.status).toBe('active');
expect(errand.conflictFiles).toBeNull();
expect(errand.createdAt).toBeInstanceOf(Date);
expect(errand.updatedAt).toBeInstanceOf(Date);
});
});
describe('findById', () => {
it('returns null for non-existent errand', async () => {
const result = await repo.findById('does-not-exist');
expect(result).toBeNull();
});
it('returns errand with agentAlias null when no agent', async () => {
const project = await createProject();
const created = await repo.create({
description: 'test',
branch: 'cw/errand/test-xyz',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.agentAlias).toBeNull();
});
});
describe('findAll', () => {
it('returns empty array when no errands', async () => {
const results = await repo.findAll();
expect(results).toEqual([]);
});
it('filters by projectId', async () => {
const projectA = await createProject();
const projectB = await createProject();
await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' });
await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' });
const results = await repo.findAll({ projectId: projectA.id });
expect(results).toHaveLength(1);
expect(results[0].description).toBe('a');
});
});
describe('update', () => {
it('updates errand status', async () => {
const project = await createProject();
const created = await repo.create({
description: 'upd test',
branch: 'cw/errand/upd',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const updated = await repo.update(created.id, { status: 'pending_review' });
expect(updated!.status).toBe('pending_review');
});
});
describe('conflictFiles column', () => {
it('stores and retrieves conflictFiles via update + findById', async () => {
const project = await createProject();
const created = await repo.create({
description: 'x',
branch: 'cw/errand/x',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' });
const found = await repo.findById(created.id);
expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]');
expect(found!.status).toBe('conflict');
});
it('returns null conflictFiles for non-conflict errands', async () => {
const project = await createProject();
const created = await repo.create({
description: 'y',
branch: 'cw/errand/y',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const found = await repo.findById(created.id);
expect(found!.conflictFiles).toBeNull();
});
it('findAll includes conflictFiles in results', async () => {
const project = await createProject();
const created = await repo.create({
description: 'z',
branch: 'cw/errand/z',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
await repo.update(created.id, { conflictFiles: '["x.ts"]' });
const all = await repo.findAll({ projectId: project.id });
expect(all[0].conflictFiles).toBe('["x.ts"]');
});
});
});

View File

@@ -35,6 +35,7 @@ vi.mock('../../git/manager.js', () => ({
vi.mock('../../git/project-clones.js', () => ({
ensureProjectClone: mockEnsureProjectClone,
getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'),
}));
vi.mock('../../agent/file-io.js', async (importOriginal) => {
@@ -393,7 +394,7 @@ describe('errand procedures', () => {
expect(result.id).toBe(errand.id);
expect(result).toHaveProperty('agentAlias');
expect(result.conflictFiles).toBeNull();
expect(result.conflictFiles).toEqual([]);
});
it('parses conflictFiles JSON when present', async () => {

View File

@@ -18,8 +18,9 @@ import {
} from './_helpers.js';
import { writeErrandManifest } from '../../agent/file-io.js';
import { buildErrandPrompt } from '../../agent/prompts/index.js';
import { join } from 'node:path';
import { SimpleGitWorktreeManager } from '../../git/manager.js';
import { ensureProjectClone } from '../../git/project-clones.js';
import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
import type { TRPCContext } from '../context.js';
// ErrandStatus values for input validation
@@ -200,10 +201,27 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
if (!errand) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
}
return {
...errand,
conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null,
};
// Parse conflictFiles; return [] on null or malformed JSON
let conflictFiles: string[] = [];
if (errand.conflictFiles) {
try {
conflictFiles = JSON.parse(errand.conflictFiles) as string[];
} catch {
conflictFiles = [];
}
}
// Compute project clone path for cw errand resolve
let projectPath: string | null = null;
if (errand.projectId && ctx.workspaceRoot) {
const project = await requireProjectRepository(ctx).findById(errand.projectId);
if (project) {
projectPath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
}
}
return { ...errand, conflictFiles, projectPath };
}),
// -----------------------------------------------------------------------