v0.1.4 — 옵션 3 (outbound + inbound). DISCORD_TOKEN 이 설정되지 않으면
bridge 는 no-op 이라 기존 배포는 영향 없음.
## Outbound (rails → Discord)
- runner.ts: PipelineLifecycleEvent emitter 추가
started / stage-done / stage-failed / completed / failed / escalated
- DiscordNotifier: 이벤트 → Discord 메시지 렌더링
thread mode (슬래시 커맨드 트리거) vs channel mode (CLI/HTTP 트리거)
- EscalationNotifier 인터페이스도 구현 — escalate.ts 에서 사용자에게 알림
- runPipeline opts 에 onEvent + notifier 주입
## Inbound (Discord → rails)
- /rails start project:<name> requirements:<text> — 파이프라인 기동
→ defer reply → POST /pipelines/start-async → thread 생성 → 실시간 업데이트
- /rails status <id> — 상태 조회 (ephemeral)
- /rails abort <id> — 강제 종료 (ephemeral)
## Async start 엔드포인트
- POST /pipelines/start-async: pipelineId 즉시 리턴 후 background 에서
runPipeline 실행. Discord 의 3초 ACK 타임아웃을 회피.
- runPipeline 에 opts.pipelineId 지원: async 엔드포인트가 미리 만든
row 위에 파이프라인을 그대로 얹을 수 있게.
## 부트스트랩
- rails serve 가 DISCORD_TOKEN/GUILD_ID/NOTIFY_CHANNEL_ID 세 개가 모두
있으면 DiscordBridge 를 자동 시작. 없으면 "skip" 로그 남기고 무시.
- discord.js ^14.26 의존성 추가.
## 문서/테스트
- .env.example: Discord 섹션 전면 재작성 (동작 설명 포함)
- tests/discord-notifier.test.ts (7 tests): fake DiscordClientWrapper 로
라우팅/렌더링/바인딩 해제 로직 검증
- 총 111 → 118 테스트 통과
216 lines
6.8 KiB
TypeScript
216 lines
6.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { DiscordNotifier } from "../src/bridge/discord-notifier.js";
|
|
import type { DiscordClientWrapper } from "../src/bridge/discord-client.js";
|
|
import type { PipelineLifecycleEvent } from "../src/orchestrator/runner.js";
|
|
|
|
/**
|
|
* The DiscordNotifier is the interesting unit — it contains the routing
|
|
* logic (channel vs thread) and the message rendering. We exercise it
|
|
* against a fake DiscordClientWrapper that records every call.
|
|
*/
|
|
|
|
function makeFakeDiscord(): {
|
|
fake: DiscordClientWrapper;
|
|
channelPosts: Array<{ channelId: string; content: string }>;
|
|
threadPosts: Array<{ threadId: string; content: string }>;
|
|
} {
|
|
const channelPosts: Array<{ channelId: string; content: string }> = [];
|
|
const threadPosts: Array<{ threadId: string; content: string }> = [];
|
|
const fake = {
|
|
config: {
|
|
token: "x",
|
|
guildId: "g",
|
|
notifyChannelId: "channel-123",
|
|
},
|
|
async postToChannel(channelId: string, content: string) {
|
|
channelPosts.push({ channelId, content });
|
|
return "msg-id";
|
|
},
|
|
async postToThread(threadId: string, content: string) {
|
|
threadPosts.push({ threadId, content });
|
|
return "msg-id";
|
|
},
|
|
} as unknown as DiscordClientWrapper;
|
|
return { fake, channelPosts, threadPosts };
|
|
}
|
|
|
|
async function flush(): Promise<void> {
|
|
// The notifier dispatches via .catch on a floating promise — give
|
|
// microtasks a chance to run before we assert.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
|
|
describe("DiscordNotifier", () => {
|
|
let fake: ReturnType<typeof makeFakeDiscord>;
|
|
let notifier: DiscordNotifier;
|
|
|
|
beforeEach(() => {
|
|
fake = makeFakeDiscord();
|
|
notifier = new DiscordNotifier(fake.fake);
|
|
});
|
|
|
|
it("posts to channel when no thread is bound", async () => {
|
|
const listener = notifier.asListener();
|
|
const evt: PipelineLifecycleEvent = {
|
|
type: "started",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
projectName: "demo",
|
|
requirements: "make a toy app",
|
|
};
|
|
listener(evt);
|
|
await flush();
|
|
|
|
expect(fake.channelPosts).toHaveLength(1);
|
|
expect(fake.threadPosts).toHaveLength(0);
|
|
expect(fake.channelPosts[0]!.channelId).toBe("channel-123");
|
|
expect(fake.channelPosts[0]!.content).toContain("파이프라인 시작");
|
|
expect(fake.channelPosts[0]!.content).toContain("demo");
|
|
});
|
|
|
|
it("posts to thread when one is bound", async () => {
|
|
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-777");
|
|
const listener = notifier.asListener();
|
|
listener({
|
|
type: "stage-done",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "plan",
|
|
text: "plan ok",
|
|
});
|
|
await flush();
|
|
|
|
expect(fake.threadPosts).toHaveLength(1);
|
|
expect(fake.channelPosts).toHaveLength(0);
|
|
expect(fake.threadPosts[0]!.threadId).toBe("thread-777");
|
|
expect(fake.threadPosts[0]!.content).toContain("기획 완료");
|
|
});
|
|
|
|
it("renders every lifecycle event type", async () => {
|
|
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-1");
|
|
const listener = notifier.asListener();
|
|
|
|
const events: PipelineLifecycleEvent[] = [
|
|
{
|
|
type: "started",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
projectName: "p",
|
|
requirements: "r",
|
|
},
|
|
{
|
|
type: "stage-done",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "implement",
|
|
text: "impl ok",
|
|
},
|
|
{
|
|
type: "stage-failed",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "review",
|
|
reason: "something broke",
|
|
},
|
|
{
|
|
type: "escalated",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "implement",
|
|
reason: "3 retries failed",
|
|
attempts: 3,
|
|
},
|
|
{
|
|
type: "completed",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
finalState: "done",
|
|
transitions: 5,
|
|
},
|
|
];
|
|
for (const e of events) listener(e);
|
|
await flush();
|
|
|
|
const contents = fake.threadPosts.map((p) => p.content);
|
|
expect(contents.some((c) => c.includes("파이프라인 시작"))).toBe(true);
|
|
expect(contents.some((c) => c.includes("구현 완료"))).toBe(true);
|
|
expect(contents.some((c) => c.includes("검토 실패"))).toBe(true);
|
|
expect(contents.some((c) => c.includes("에스컬레이션"))).toBe(true);
|
|
expect(contents.some((c) => c.includes("파이프라인 완료"))).toBe(true);
|
|
});
|
|
|
|
it("unbinds thread after terminal 'completed' event", async () => {
|
|
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-2");
|
|
const listener = notifier.asListener();
|
|
listener({
|
|
type: "completed",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
finalState: "done",
|
|
transitions: 5,
|
|
});
|
|
await flush();
|
|
|
|
// A subsequent event should fall back to channel (thread unbound)
|
|
listener({
|
|
type: "started",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
projectName: "x",
|
|
requirements: "y",
|
|
});
|
|
await flush();
|
|
|
|
// First post to thread, second to channel
|
|
expect(fake.threadPosts).toHaveLength(1);
|
|
expect(fake.channelPosts).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps thread binding after 'escalated' (user may resume)", async () => {
|
|
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-3");
|
|
const listener = notifier.asListener();
|
|
listener({
|
|
type: "escalated",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "plan",
|
|
reason: "halt",
|
|
attempts: 2,
|
|
});
|
|
await flush();
|
|
|
|
listener({
|
|
type: "stage-done",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
stage: "plan",
|
|
text: "resumed",
|
|
});
|
|
await flush();
|
|
|
|
expect(fake.threadPosts).toHaveLength(2);
|
|
expect(fake.channelPosts).toHaveLength(0);
|
|
});
|
|
|
|
it("EscalationNotifier.notify posts to channel", async () => {
|
|
await notifier.notify({
|
|
title: "🚨 pipeline halted",
|
|
body: "three retries exhausted",
|
|
mentionUser: true,
|
|
});
|
|
expect(fake.channelPosts).toHaveLength(1);
|
|
expect(fake.channelPosts[0]!.content).toContain("pipeline halted");
|
|
expect(fake.channelPosts[0]!.content).toContain("three retries exhausted");
|
|
});
|
|
|
|
it("swallows listener exceptions from postToChannel (non-fatal)", async () => {
|
|
const warnSpy = vi.fn();
|
|
// Inject a failing postToChannel
|
|
const failingFake = makeFakeDiscord();
|
|
(failingFake.fake as unknown as {
|
|
postToChannel: () => Promise<string>;
|
|
}).postToChannel = async () => {
|
|
throw new Error("boom");
|
|
};
|
|
const failingNotifier = new DiscordNotifier(failingFake.fake);
|
|
failingNotifier.asListener()({
|
|
type: "started",
|
|
pipelineId: "01HXYZTEST1234567890ABCDE",
|
|
projectName: "p",
|
|
requirements: "r",
|
|
});
|
|
await flush();
|
|
// If we get here without an uncaught rejection, the test passes.
|
|
expect(warnSpy).toBeDefined();
|
|
});
|
|
});
|