feat(#172): Implement Herald status updates
Implements status broadcasting via bridge module to chat channels. The Herald service subscribes to job events and broadcasts status updates to Discord threads using PDA-friendly language. Features: - Herald module with HeraldService for status broadcasting - Subscribe to job lifecycle, step lifecycle, and gate events - Format messages with PDA-friendly language (no "FAILED", "URGENT", etc.) - Visual indicators for quick scanning (🟢, 🔵, ✅, ⚠️, ⏸️) - Channel selection logic via workspace settings - Route to Discord threads based on job metadata - Comprehensive unit tests (14 tests passing, 85%+ coverage) Message format examples: - Job created: 🟢 Job created for #42 - Job started: 🔵 Job started for #42 - Job completed: ✅ Job completed for #42 (120s) - Job failed: ⚠️ Job encountered an issue for #42 - Gate passed: ✅ Gate passed: build - Gate failed: ⚠️ Gate needs attention: test Quality gates: ✅ typecheck, lint, test, build PR comment support deferred - requires GitHub/Gitea API client implementation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
525
apps/api/src/herald/herald.service.spec.ts
Normal file
525
apps/api/src/herald/herald.service.spec.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { HeraldService } from "./herald.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { DiscordService } from "../bridge/discord/discord.service";
|
||||
import {
|
||||
JOB_CREATED,
|
||||
JOB_STARTED,
|
||||
JOB_COMPLETED,
|
||||
JOB_FAILED,
|
||||
STEP_STARTED,
|
||||
STEP_COMPLETED,
|
||||
GATE_PASSED,
|
||||
GATE_FAILED,
|
||||
} from "../job-events/event-types";
|
||||
|
||||
describe("HeraldService", () => {
|
||||
let service: HeraldService;
|
||||
let prisma: PrismaService;
|
||||
let discord: DiscordService;
|
||||
|
||||
const mockPrisma = {
|
||||
workspace: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
runnerJob: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
jobEvent: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockDiscord = {
|
||||
isConnected: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
sendThreadMessage: vi.fn(),
|
||||
createThread: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HeraldService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrisma,
|
||||
},
|
||||
{
|
||||
provide: DiscordService,
|
||||
useValue: mockDiscord,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HeraldService>(HeraldService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
discord = module.get<DiscordService>(DiscordService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("broadcastJobEvent", () => {
|
||||
it("should broadcast job.created event to configured channel", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { issueNumber: 42 },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: {
|
||||
herald: {
|
||||
channelMappings: {
|
||||
"code-task": "channel-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { issueNumber: 42, threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job created"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should broadcast job.started event", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_STARTED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("Job started"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should broadcast job.completed event with success message", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_COMPLETED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { duration: 120 },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("completed"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should broadcast job.failed event with PDA-friendly language", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_FAILED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: { error: "Build failed" },
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
mockDiscord.sendThreadMessage.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).toHaveBeenCalledWith({
|
||||
threadId: "thread-123",
|
||||
content: expect.stringContaining("encountered an issue"),
|
||||
});
|
||||
// Verify the actual message doesn't contain demanding language
|
||||
const actualCall = mockDiscord.sendThreadMessage.mock.calls[0][0];
|
||||
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
|
||||
});
|
||||
|
||||
it("should skip broadcasting if Discord is not connected", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: { threadId: "thread-123" },
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(false);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should skip broadcasting if job has no threadId", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobId = "job-1";
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId,
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date(),
|
||||
actor: "system",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: { herald: { channelMappings: {} } },
|
||||
});
|
||||
|
||||
mockPrisma.runnerJob.findUnique.mockResolvedValue({
|
||||
id: jobId,
|
||||
workspaceId,
|
||||
type: "code-task",
|
||||
});
|
||||
|
||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||
payload: {
|
||||
metadata: {}, // No threadId
|
||||
},
|
||||
});
|
||||
|
||||
mockDiscord.isConnected.mockReturnValue(true);
|
||||
|
||||
// Act
|
||||
await service.broadcastJobEvent(jobId, event);
|
||||
|
||||
// Assert
|
||||
expect(mockDiscord.sendThreadMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJobEventMessage", () => {
|
||||
it("should format job.created message with 10-second scannability", () => {
|
||||
// Arrange
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId: "job-1",
|
||||
type: JOB_CREATED,
|
||||
timestamp: new Date("2026-01-01T12:00:00Z"),
|
||||
actor: "system",
|
||||
payload: { issueNumber: 42 },
|
||||
};
|
||||
|
||||
const job = {
|
||||
id: "job-1",
|
||||
type: "code-task",
|
||||
};
|
||||
|
||||
const metadata = { issueNumber: 42 };
|
||||
|
||||
// Act
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toContain("🟢");
|
||||
expect(message).toContain("Job created");
|
||||
expect(message).toContain("#42");
|
||||
expect(message.length).toBeLessThan(200); // Keep it scannable
|
||||
});
|
||||
|
||||
it("should format job.completed message with visual indicator", () => {
|
||||
// Arrange
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId: "job-1",
|
||||
type: JOB_COMPLETED,
|
||||
timestamp: new Date("2026-01-01T12:00:00Z"),
|
||||
actor: "system",
|
||||
payload: { duration: 120 },
|
||||
};
|
||||
|
||||
const job = {
|
||||
id: "job-1",
|
||||
type: "code-task",
|
||||
};
|
||||
|
||||
const metadata = { issueNumber: 42 };
|
||||
|
||||
// Act
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toMatch(/✅|🟢/);
|
||||
expect(message).toContain("completed");
|
||||
expect(message).not.toMatch(/COMPLETED|SUCCESS/);
|
||||
});
|
||||
|
||||
it("should format step.completed message", () => {
|
||||
// Arrange
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId: "job-1",
|
||||
stepId: "step-1",
|
||||
type: STEP_COMPLETED,
|
||||
timestamp: new Date("2026-01-01T12:00:00Z"),
|
||||
actor: "system",
|
||||
payload: { stepName: "Run tests" },
|
||||
};
|
||||
|
||||
const job = {
|
||||
id: "job-1",
|
||||
type: "code-task",
|
||||
};
|
||||
|
||||
const metadata = { issueNumber: 42 };
|
||||
|
||||
// Act
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toContain("Step completed");
|
||||
expect(message).toContain("Run tests");
|
||||
});
|
||||
|
||||
it("should format gate.passed message", () => {
|
||||
// Arrange
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId: "job-1",
|
||||
type: GATE_PASSED,
|
||||
timestamp: new Date("2026-01-01T12:00:00Z"),
|
||||
actor: "system",
|
||||
payload: { gateName: "build" },
|
||||
};
|
||||
|
||||
const job = {
|
||||
id: "job-1",
|
||||
type: "code-task",
|
||||
};
|
||||
|
||||
const metadata = { issueNumber: 42 };
|
||||
|
||||
// Act
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toContain("Gate passed");
|
||||
expect(message).toContain("build");
|
||||
});
|
||||
|
||||
it("should format gate.failed message with PDA-friendly language", () => {
|
||||
// Arrange
|
||||
const event = {
|
||||
id: "event-1",
|
||||
jobId: "job-1",
|
||||
type: GATE_FAILED,
|
||||
timestamp: new Date("2026-01-01T12:00:00Z"),
|
||||
actor: "system",
|
||||
payload: { gateName: "test", error: "2 tests failed" },
|
||||
};
|
||||
|
||||
const job = {
|
||||
id: "job-1",
|
||||
type: "code-task",
|
||||
};
|
||||
|
||||
const metadata = { issueNumber: 42 };
|
||||
|
||||
// Act
|
||||
const message = service.formatJobEventMessage(event, job, metadata);
|
||||
|
||||
// Assert
|
||||
expect(message).toContain("Gate needs attention");
|
||||
expect(message).toContain("test");
|
||||
expect(message).not.toMatch(/FAILED|ERROR|CRITICAL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChannelForJobType", () => {
|
||||
it("should return channel from workspace settings", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobType = "code-task";
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: {
|
||||
herald: {
|
||||
channelMappings: {
|
||||
"code-task": "channel-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
||||
|
||||
// Assert
|
||||
expect(channelId).toBe("channel-123");
|
||||
});
|
||||
|
||||
it("should return default channel if job type not mapped", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobType = "code-task";
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: {
|
||||
herald: {
|
||||
channelMappings: {},
|
||||
defaultChannel: "default-channel",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
||||
|
||||
// Assert
|
||||
expect(channelId).toBe("default-channel");
|
||||
});
|
||||
|
||||
it("should return null if no channel configured", async () => {
|
||||
// Arrange
|
||||
const workspaceId = "workspace-1";
|
||||
const jobType = "code-task";
|
||||
|
||||
mockPrisma.workspace.findUnique.mockResolvedValue({
|
||||
id: workspaceId,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
const channelId = await service.getChannelForJobType(workspaceId, jobType);
|
||||
|
||||
// Assert
|
||||
expect(channelId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user