feat(#171): Implement chat command parsing

Add command parsing layer for chat integration (Discord, Mattermost, Slack).

Features:
- Parse @mosaic commands with action dispatch
- Support 3 issue reference formats: #42, owner/repo#42, full URL
- Handle 7 actions: fix, status, cancel, retry, verbose, quiet, help
- Comprehensive error handling with helpful messages
- Case-insensitive parsing
- Platform-agnostic design

Implementation:
- CommandParserService with tokenizer and action dispatcher
- Regex-based issue reference parsing
- Type-safe command structures
- 24 unit tests with 100% coverage

TDD approach:
- RED: Wrote comprehensive tests first
- GREEN: Implemented parser to pass all tests
- REFACTOR: Fixed TypeScript strict mode and linting issues

Quality gates passed:
- ✓ Typecheck
- ✓ Lint
- ✓ Build
- ✓ Tests (24/24 passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:32:53 -06:00
parent 4ac21d1a3a
commit e689a1379c
40 changed files with 1618 additions and 6 deletions

View File

@@ -20,6 +20,9 @@ describe("RunnerJobsService", () => {
findUnique: vi.fn(),
update: vi.fn(),
},
jobEvent: {
findMany: vi.fn(),
},
};
const mockBullMqService = {
@@ -524,4 +527,113 @@ describe("RunnerJobsService", () => {
await expect(service.retry(jobId, workspaceId)).rejects.toThrow("Can only retry failed jobs");
});
});
describe("streamEvents", () => {
it("should stream events and close when job completes", async () => {
const jobId = "job-123";
const workspaceId = "workspace-123";
// Mock response object
const mockRes = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
writableEnded: false,
setHeader: vi.fn(),
};
// Mock initial job lookup
mockPrismaService.runnerJob.findUnique
.mockResolvedValueOnce({
id: jobId,
status: RunnerJobStatus.RUNNING,
})
.mockResolvedValueOnce({
id: jobId,
status: RunnerJobStatus.COMPLETED, // Second call for status check
});
// Mock events
const mockEvents = [
{
id: "event-1",
jobId,
stepId: "step-1",
type: "step.started",
timestamp: new Date(),
payload: { name: "Running tests", phase: "validation" },
},
];
mockPrismaService.jobEvent.findMany.mockResolvedValue(mockEvents);
// Execute streamEvents
await service.streamEvents(jobId, workspaceId, mockRes as never);
// Verify job lookup was called
expect(prisma.runnerJob.findUnique).toHaveBeenCalledWith({
where: { id: jobId, workspaceId },
select: { id: true, status: true },
});
// Verify events were written
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining("step.started"));
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining("stream.complete"));
expect(mockRes.end).toHaveBeenCalled();
});
it("should throw NotFoundException if job not found", async () => {
const jobId = "nonexistent-job";
const workspaceId = "workspace-123";
const mockRes = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue(null);
await expect(service.streamEvents(jobId, workspaceId, mockRes as never)).rejects.toThrow(
NotFoundException
);
await expect(service.streamEvents(jobId, workspaceId, mockRes as never)).rejects.toThrow(
`RunnerJob with ID ${jobId} not found`
);
});
it("should clean up interval on connection close", async () => {
const jobId = "job-123";
const workspaceId = "workspace-123";
let closeHandler: (() => void) | null = null;
const mockRes = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
if (event === "close") {
closeHandler = handler;
// Immediately trigger close to break the loop
setTimeout(() => handler(), 10);
}
}),
writableEnded: false,
};
mockPrismaService.runnerJob.findUnique.mockResolvedValue({
id: jobId,
status: RunnerJobStatus.RUNNING,
});
mockPrismaService.jobEvent.findMany.mockResolvedValue([]);
// Start streaming and wait for it to complete
await service.streamEvents(jobId, workspaceId, mockRes as never);
// Verify cleanup
expect(mockRes.on).toHaveBeenCalledWith("close", expect.any(Function));
expect(mockRes.end).toHaveBeenCalled();
});
});
});