From 102c5d887fd48eedce8d81e4804e8b845a4c409b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:03:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=EC=BB=A4=EB=B0=8B=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=AA=85=EC=82=AC=ED=98=95=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/project-workflows.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/rules/project-workflows.md b/.agents/rules/project-workflows.md index d2520fa..26cc318 100644 --- a/.agents/rules/project-workflows.md +++ b/.agents/rules/project-workflows.md @@ -56,6 +56,7 @@ This reference holds Watcher-specific working rules that should live with the pr - Commit messages must start with a prefix such as `feat`, `fix`, `refactor`, or `chore`. - Write commit message prose in Korean. +- 커밋 메시지의 한국어 설명은 서술형 종결어미를 사용하지 않고 `구성`, `추가`, `수정`, `정리` 같은 명사형으로 끝낸다. - Keep implementation names, file paths, commands, branch names, workflow names, issue numbers, and commit hashes in their original form. - Do not write a commit message body. - Commit only files related to the current change. From 44e5661b3593c30fe746fdf0a164deebe32c6f7a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:08:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20Discord=20report=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=8B=A8=EC=9C=84=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/discordMessageSplitter.ts | 151 ++++++++++++++++++ src/reportChannels/reportChannel.ts | 53 +----- .../discordMessageSplitter.test.ts | 63 ++++++++ tests/reportChannels/reportChannel.test.ts | 116 ++++++++++++++ 4 files changed, 332 insertions(+), 51 deletions(-) create mode 100644 src/reportChannels/discordMessageSplitter.ts create mode 100644 tests/reportChannels/discordMessageSplitter.test.ts diff --git a/src/reportChannels/discordMessageSplitter.ts b/src/reportChannels/discordMessageSplitter.ts new file mode 100644 index 0000000..0463308 --- /dev/null +++ b/src/reportChannels/discordMessageSplitter.ts @@ -0,0 +1,151 @@ +const DISCORD_CONTENT_LIMIT = 2000 +const REPORT_TITLE = "## Merge Risk Report" +const SUMMARY_HEADING = "### Summary" +const SECTION_HEADING_PREFIX = "### " +const PAIR_HEADING_PREFIX = "#### " +const PAIR_SECTION_HEADINGS = new Set([ + "### Confirmed Conflicts", + "### Potential Risks" +]) + +// Discord 전송 내용과 branch 조합별 조각 위치 +export type DiscordMessage = { + content: string + pairLabel?: string + fragmentNumber: number +} + +type DiscordMessageBlock = { + lines: string[] + pairLabel?: string +} + +// report 구조를 인식하면 section과 branch 조합 단위로, 아니면 길이만으로 분할 +export function splitDiscordMessages(markdown: string): DiscordMessage[] { + const blocks = structuralBlocksFor(markdown) + + if (!blocks) { + return messagesFor({ lines: [markdown] }) + } + + return blocks.flatMap(messagesFor) +} + +// Summary, section, branch 조합 경계를 원래 line 순서대로 block으로 구성 +function structuralBlocksFor(markdown: string): DiscordMessageBlock[] | undefined { + const lines = markdown.split("\n") + + if (lines[0] !== REPORT_TITLE || !lines.includes(SUMMARY_HEADING)) { + return undefined + } + + const blocks: DiscordMessageBlock[] = [] + let block: DiscordMessageBlock = { lines: [] } + + for (const line of lines) { + if (line.startsWith(SECTION_HEADING_PREFIX) && line !== SUMMARY_HEADING) { + appendBlock(blocks, block) + block = { lines: [line] } + continue + } + + if (line.startsWith(PAIR_HEADING_PREFIX)) { + const pairLabel = line.slice(PAIR_HEADING_PREFIX.length) + + if (isPairSectionPreamble(block)) { + block.lines.push(line) + block.pairLabel = pairLabel + continue + } + + appendBlock(blocks, block) + block = { + lines: [line], + pairLabel + } + continue + } + + block.lines.push(line) + } + + appendBlock(blocks, block) + return blocks +} + +// branch 조합 section 제목과 빈 line을 첫 조합 message 앞에 유지 +function isPairSectionPreamble(block: DiscordMessageBlock): boolean { + const [heading, ...lines] = block.lines + + return block.pairLabel === undefined && + heading !== undefined && + PAIR_SECTION_HEADINGS.has(heading) && + lines.every(line => line.length === 0) +} + +// 빈 block은 제외하고 원래 line을 가진 block만 결과에 추가 +function appendBlock( + blocks: DiscordMessageBlock[], + block: DiscordMessageBlock +): void { + if (block.lines.length) { + blocks.push(block) + } +} + +// 한 구조 block을 Discord 길이 제한에 맞는 message로 변환 +function messagesFor(block: DiscordMessageBlock): DiscordMessage[] { + return contentsFor(block.lines.join("\n")).map((content, index) => ({ + content, + pairLabel: block.pairLabel, + fragmentNumber: index + 1 + })) +} + +// Discord content 최대 길이를 넘지 않도록 줄 단위로 최대한 보존하며 분할 +function contentsFor(markdown: string): string[] { + if (markdown.length <= DISCORD_CONTENT_LIMIT) { + return [markdown] + } + + const contents: string[] = [] + let current = "" + + for (const line of markdown.split("\n")) { + const next = current.length === 0 ? line : `${current}\n${line}` + + if (next.length <= DISCORD_CONTENT_LIMIT) { + current = next + continue + } + + if (current.length) { + contents.push(current) + } + + if (line.length <= DISCORD_CONTENT_LIMIT) { + current = line + continue + } + + contents.push(...chunksFor(line)) + current = "" + } + + if (current.length) { + contents.push(current) + } + + return contents +} + +// 한 줄 자체가 Discord 제한보다 길면 고정 길이 chunk로 분리 +function chunksFor(value: string): string[] { + const chunks: string[] = [] + + for (let index = 0; index < value.length; index += DISCORD_CONTENT_LIMIT) { + chunks.push(value.slice(index, index + DISCORD_CONTENT_LIMIT)) + } + + return chunks +} diff --git a/src/reportChannels/reportChannel.ts b/src/reportChannels/reportChannel.ts index 67cad25..af889c4 100644 --- a/src/reportChannels/reportChannel.ts +++ b/src/reportChannels/reportChannel.ts @@ -4,8 +4,7 @@ import { type ReportChannelOptions, type ReportChannelResult } from "./types.js" - -const DISCORD_CONTENT_LIMIT = 2000 +import { splitDiscordMessages } from "./discordMessageSplitter.js" // Discord webhook URL이 있으면 Discord channel로 보내고 없으면 stdout으로 fallback export async function send( @@ -69,7 +68,7 @@ async function sendDiscord( headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: message }) + body: JSON.stringify({ content: message.content }) }) if (!response.ok) { @@ -95,54 +94,6 @@ async function sendDiscord( } } -// Discord content 최대 길이를 넘는 report를 줄 단위로 최대한 보존하며 분할 -function splitDiscordMessages(markdown: string): string[] { - if (markdown.length <= DISCORD_CONTENT_LIMIT) { - return [markdown] - } - - const messages: string[] = [] - let current = "" - - for (const line of markdown.split("\n")) { - const next = current.length === 0 ? line : `${current}\n${line}` - - if (next.length <= DISCORD_CONTENT_LIMIT) { - current = next - continue - } - - if (current.length) { - messages.push(current) - } - - if (line.length <= DISCORD_CONTENT_LIMIT) { - current = line - continue - } - - messages.push(...chunksFor(line)) - current = "" - } - - if (current.length) { - messages.push(current) - } - - return messages -} - -// 한 줄 자체가 Discord 제한보다 길면 고정 길이 chunk로 분리 -function chunksFor(value: string): string[] { - const chunks: string[] = [] - - for (let index = 0; index < value.length; index += DISCORD_CONTENT_LIMIT) { - chunks.push(value.slice(index, index + DISCORD_CONTENT_LIMIT)) - } - - return chunks -} - // unknown error를 report 가능한 문자열로 변환 function errorMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) diff --git a/tests/reportChannels/discordMessageSplitter.test.ts b/tests/reportChannels/discordMessageSplitter.test.ts new file mode 100644 index 0000000..951ec4a --- /dev/null +++ b/tests/reportChannels/discordMessageSplitter.test.ts @@ -0,0 +1,63 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + splitDiscordMessages +} from "../../src/reportChannels/discordMessageSplitter.js" + +// Summary를 첫 message로 두고 각 branch 조합을 새 message에서 시작하는지 확인 +test("places summary first and starts each branch pair in a new message", () => { + const messages = splitDiscordMessages([ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 2", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "", + "#### `feature/b` ↔ `main`", + "- status: `confirmed_conflict`", + "", + "### Branch Impact", + "", + "- `feature/a`" + ].join("\n")) + + assert.match(messages[0]?.content ?? "", /### Summary/) + assert.match( + messages[1]?.content ?? "", + /^### Confirmed Conflicts\n\n#### `feature\/a` ↔ `main`/ + ) + assert.match( + messages[2]?.content ?? "", + /^#### `feature\/b` ↔ `main`/ + ) + assert.match(messages.at(-1)?.content ?? "", /### Branch Impact/) + assert.equal(messages.every(message => message.content.length <= 2000), true) + assert.deepEqual(messages.slice(1, 3).map(message => message.pairLabel), [ + "`feature/a` ↔ `main`", + "`feature/b` ↔ `main`" + ]) +}) + +// Discord content 제한 경계에서 불필요하거나 누락된 message가 없는지 확인 +test("keeps an exact 2000 character message and splits a 2001 character message", () => { + assert.deepEqual( + splitDiscordMessages("a".repeat(2000)).map(message => message.content.length), + [2000] + ) + assert.deepEqual( + splitDiscordMessages("a".repeat(2001)).map(message => message.content.length), + [2000, 1] + ) +}) + +// 알려지지 않은 Markdown도 길이 기반 fallback으로 원문을 모두 유지하는지 확인 +test("falls back to length splitting without dropping unrecognized markdown", () => { + const markdown = `custom:${"x".repeat(2100)}` + const messages = splitDiscordMessages(markdown) + + assert.equal(messages.map(message => message.content).join(""), markdown) +}) diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index cc88ff8..6a1ff54 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -140,6 +140,95 @@ test("splits long discord report into multiple messages", async () => { assert.equal(fetcher.requests[1]?.body.content.length, 1) }) +// report section과 branch 조합을 원래 순서대로 모두 Discord에 전송하는지 확인 +test("sends report sections and branch pairs in source order", async () => { + const fetcher = fetchSpy({}) + const result = await sendMergeRiskReport({ + markdown: [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 2", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "", + "#### `feature/b` ↔ `main`", + "- status: `confirmed_conflict`", + "", + "### Branch Impact", + "", + "- `feature/a`", + "", + "### Excluded Branches", + "", + "- `feature/old`: `stale_branch`", + "", + "### Merge Errors", + "", + "없음" + ].join("\n") + }, { + discordWebhookUrl: "https://discord.test/webhook", + fetch: fetcher + }) + + assert.deepEqual(result, { + ok: true, + target: "discord", + messageCount: 6 + }) + assert.match(fetcher.requests[0]?.body.content ?? "", /### Summary/) + assert.match(fetcher.requests[1]?.body.content ?? "", /`feature\/a` ↔ `main`/) + assert.match(fetcher.requests[2]?.body.content ?? "", /`feature\/b` ↔ `main`/) + assert.match(fetcher.requests[3]?.body.content ?? "", /### Branch Impact/) + assert.match(fetcher.requests[4]?.body.content ?? "", /### Excluded Branches/) + assert.match(fetcher.requests[5]?.body.content ?? "", /### Merge Errors/) +}) + +// 이전 Discord request가 끝난 뒤에만 다음 message 전송을 시작하는지 확인 +test("waits for each discord request before sending the next message", async () => { + const fetcher = new DeferredFetchSpy() + const result = sendMergeRiskReport({ + markdown: [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 2", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "", + "#### `feature/b` ↔ `main`", + "- status: `confirmed_conflict`" + ].join("\n") + }, { + discordWebhookUrl: "https://discord.test/webhook", + fetch: fetcher.fetch + }) + + assert.equal(fetcher.requests.length, 1) + + fetcher.resolveNext() + await nextEventLoopTurn() + assert.equal(fetcher.requests.length, 2) + + fetcher.resolveNext() + await nextEventLoopTurn() + assert.equal(fetcher.requests.length, 3) + + fetcher.resolveNext() + assert.deepEqual(await result, { + ok: true, + target: "discord", + messageCount: 3 + }) +}) + class StdoutSpy { output = "" @@ -191,3 +280,30 @@ function failingFetch(message: string): typeof fetch { throw new Error(message) }) as typeof fetch } + +class DeferredFetchSpy { + readonly requests: Request[] = [] + private readonly resolvers: Array<(response: Response) => void> = [] + + readonly fetch = (async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + this.requests.push(new Request(input, init)) + + return new Promise(resolve => { + this.resolvers.push(resolve) + }) + }) as typeof fetch + + resolveNext(): void { + this.resolvers.shift()?.({ + ok: true, + status: 204 + } as Response) + } +} + +function nextEventLoopTurn(): Promise { + return new Promise(resolve => setImmediate(resolve)) +} From a597906796cda6e76a8db7002bb5c87e2379e2e2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:15:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20Discord=20AI=20report=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=B6=84=ED=95=A0=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/discordMessageSplitter.ts | 114 ++++++++++++-- .../discordMessageSplitter.test.ts | 141 ++++++++++++++++++ 2 files changed, 243 insertions(+), 12 deletions(-) diff --git a/src/reportChannels/discordMessageSplitter.ts b/src/reportChannels/discordMessageSplitter.ts index 0463308..13776eb 100644 --- a/src/reportChannels/discordMessageSplitter.ts +++ b/src/reportChannels/discordMessageSplitter.ts @@ -7,6 +7,11 @@ const PAIR_SECTION_HEADINGS = new Set([ "### Confirmed Conflicts", "### Potential Risks" ]) +const PAIR_UNIT_BOUNDARIES = new Set([ + "- AI Analysis:", + "- Recommended Resolution:", + "- Suggested Patch:" +]) // Discord 전송 내용과 branch 조합별 조각 위치 export type DiscordMessage = { @@ -28,7 +33,7 @@ export function splitDiscordMessages(markdown: string): DiscordMessage[] { return messagesFor({ lines: [markdown] }) } - return blocks.flatMap(messagesFor) + return numberedPairMessagesFor(blocks.flatMap(messagesFor)) } // Summary, section, branch 조합 경계를 원래 line 순서대로 block으로 구성 @@ -66,6 +71,18 @@ function structuralBlocksFor(markdown: string): DiscordMessageBlock[] | undefine continue } + if (block.pairLabel && PAIR_UNIT_BOUNDARIES.has(line)) { + appendBlock(blocks, block) + block = { + lines: [ + `${PAIR_HEADING_PREFIX}${block.pairLabel}`, + line + ], + pairLabel: block.pairLabel + } + continue + } + block.lines.push(line) } @@ -95,24 +112,64 @@ function appendBlock( // 한 구조 block을 Discord 길이 제한에 맞는 message로 변환 function messagesFor(block: DiscordMessageBlock): DiscordMessage[] { - return contentsFor(block.lines.join("\n")).map((content, index) => ({ + const continuationHeading = continuationHeadingFor(block.pairLabel) + + return contentsFor( + block.lines.join("\n"), + continuationHeading + ).map((content, index) => ({ content, pairLabel: block.pairLabel, fragmentNumber: index + 1 })) } +// 같은 branch 조합의 message에 입력 순서대로 연속된 조각 번호를 부여 +function numberedPairMessagesFor(messages: DiscordMessage[]): DiscordMessage[] { + const fragmentNumbers = new Map() + + return messages.map(message => { + if (!message.pairLabel) { + return message + } + + const fragmentNumber = (fragmentNumbers.get(message.pairLabel) ?? 0) + 1 + fragmentNumbers.set(message.pairLabel, fragmentNumber) + + return { + ...message, + fragmentNumber + } + }) +} + +// 후속 message에서 반복해도 content 제한을 지킬 수 있는 조합 제목만 반환 +function continuationHeadingFor(pairLabel: string | undefined): string | undefined { + if (!pairLabel) { + return undefined + } + + const heading = `${PAIR_HEADING_PREFIX}${pairLabel}` + return heading.length + 1 < DISCORD_CONTENT_LIMIT ? heading : undefined +} + // Discord content 최대 길이를 넘지 않도록 줄 단위로 최대한 보존하며 분할 -function contentsFor(markdown: string): string[] { +function contentsFor( + markdown: string, + continuationHeading?: string +): string[] { if (markdown.length <= DISCORD_CONTENT_LIMIT) { return [markdown] } const contents: string[] = [] let current = "" + let isFirst = true for (const line of markdown.split("\n")) { - const next = current.length === 0 ? line : `${current}\n${line}` + const next = current.length === 0 + ? continuedContentFor(line, isFirst, continuationHeading) + : `${current}\n${line}` if (next.length <= DISCORD_CONTENT_LIMIT) { current = next @@ -121,14 +178,21 @@ function contentsFor(markdown: string): string[] { if (current.length) { contents.push(current) + isFirst = false } - if (line.length <= DISCORD_CONTENT_LIMIT) { - current = line + const continued = continuedContentFor(line, isFirst, continuationHeading) + + if (continued.length <= DISCORD_CONTENT_LIMIT) { + current = continued continue } - contents.push(...chunksFor(line)) + for (const chunk of chunksFor(line, isFirst, continuationHeading)) { + contents.push(chunk) + isFirst = false + } + current = "" } @@ -139,12 +203,38 @@ function contentsFor(markdown: string): string[] { return contents } -// 한 줄 자체가 Discord 제한보다 길면 고정 길이 chunk로 분리 -function chunksFor(value: string): string[] { - const chunks: string[] = [] +// 두 번째 조각부터 branch 조합 제목을 앞에 다시 붙여 문맥을 유지 +function continuedContentFor( + value: string, + isFirst: boolean, + continuationHeading?: string +): string { + if (isFirst || !continuationHeading) { + return value + } - for (let index = 0; index < value.length; index += DISCORD_CONTENT_LIMIT) { - chunks.push(value.slice(index, index + DISCORD_CONTENT_LIMIT)) + return `${continuationHeading}\n${value}` +} + +// 한 줄 자체가 제한보다 길면 반복 제목을 포함한 고정 길이 chunk로 분리 +function chunksFor( + value: string, + isFirst: boolean, + continuationHeading?: string +): string[] { + const chunks: string[] = [] + let remaining = value + + while (remaining.length) { + const headingLength = isFirst || !continuationHeading + ? 0 + : continuationHeading.length + 1 + const contentLength = DISCORD_CONTENT_LIMIT - headingLength + const chunk = remaining.slice(0, contentLength) + + chunks.push(continuedContentFor(chunk, isFirst, continuationHeading)) + remaining = remaining.slice(contentLength) + isFirst = false } return chunks diff --git a/tests/reportChannels/discordMessageSplitter.test.ts b/tests/reportChannels/discordMessageSplitter.test.ts index 951ec4a..8993839 100644 --- a/tests/reportChannels/discordMessageSplitter.test.ts +++ b/tests/reportChannels/discordMessageSplitter.test.ts @@ -61,3 +61,144 @@ test("falls back to length splitting without dropping unrecognized markdown", () assert.equal(messages.map(message => message.content).join(""), markdown) }) + +// AI 분석과 권장 해결 방법을 같은 조합의 독립 message로 분할하는지 확인 +test("starts AI analysis and recommended resolution in separate pair messages", () => { + const messages = splitDiscordMessages([ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `predicted`", + "- Recommended Resolution:", + " - strategy: `merge`" + ].join("\n")) + const pairMessages = messages.filter(message => message.pairLabel) + + assert.equal(pairMessages.length, 3) + assert.match( + pairMessages[0]?.content ?? "", + /- status: `confirmed_conflict`/ + ) + assert.match( + pairMessages[1]?.content ?? "", + /^#### `feature\/a` ↔ `main`\n- AI Analysis:/ + ) + assert.match( + pairMessages[2]?.content ?? "", + /^#### `feature\/a` ↔ `main`\n- Recommended Resolution:/ + ) + assert.deepEqual( + pairMessages.map(message => message.fragmentNumber), + [1, 2, 3] + ) +}) + +// AI 상태와 상세 단위를 조합별 순서와 연속된 조각 번호로 유지하는지 확인 +test("keeps AI units in pair order with continuous fragment numbers", () => { + const messages = splitDiscordMessages([ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 4", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `skipped`", + " - reason: `not_target`", + "", + "#### `feature/b` ↔ `main`", + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `failed`", + " - error: provider failed", + "", + "#### `feature/c` ↔ `main`", + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `predicted`", + "- Recommended Resolution:", + " - strategy: `merge`", + "- Suggested Patch:", + " - `src/c.ts`: resolve conflict", + " ```diff", + " +const resolved = true", + " ```", + "", + "### Potential Risks", + "", + "#### `feature/d` ↔ `main`", + "- status: `potential_overlap`", + "- AI Analysis:", + " - status: `predicted`", + "- Recommended Resolution:", + " - strategy: `rebase`", + "- Preventive Actions:", + " - Separate edits: move changes to another file" + ].join("\n")) + const pairMessages = messages.filter(message => message.pairLabel) + + assert.deepEqual( + pairMessages.map(message => [message.pairLabel, message.fragmentNumber]), + [ + ["`feature/a` ↔ `main`", 1], + ["`feature/a` ↔ `main`", 2], + ["`feature/b` ↔ `main`", 1], + ["`feature/b` ↔ `main`", 2], + ["`feature/c` ↔ `main`", 1], + ["`feature/c` ↔ `main`", 2], + ["`feature/c` ↔ `main`", 3], + ["`feature/c` ↔ `main`", 4], + ["`feature/d` ↔ `main`", 1], + ["`feature/d` ↔ `main`", 2], + ["`feature/d` ↔ `main`", 3] + ] + ) + assert.match(pairMessages[7]?.content ?? "", /- Suggested Patch:/) + assert.match(pairMessages[10]?.content ?? "", /- Preventive Actions:/) + assert.match( + pairMessages[10]?.content ?? "", + /^#### `feature\/d` ↔ `main`\n- Recommended Resolution:/ + ) +}) + +// 긴 AI 단위의 후속 조각에도 branch 조합 제목과 연속 번호를 유지하는지 확인 +test("repeats the pair heading for continued AI fragments", () => { + const pairLabel = "`feature/a` ↔ `main`" + const messages = splitDiscordMessages([ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + `#### ${pairLabel}`, + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `failed`", + ` - error: ${"x".repeat(4100)}` + ].join("\n")) + const pairMessages = messages.filter(message => message.pairLabel) + + assert.equal(pairMessages.every(message => message.content.length <= 2000), true) + assert.equal( + pairMessages.slice(1).every(message => + message.content.startsWith(`#### ${pairLabel}\n`) + ), + true + ) + assert.deepEqual( + pairMessages.map(message => message.fragmentNumber), + pairMessages.map((_, index) => index + 1) + ) +}) From 0ef1cde251455245daed9cf0f03aaaa00eb33b28 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:27:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B8=B4=20Suggested=20Patch=20?= =?UTF-8?q?=EC=88=9C=EB=B2=88=20=EB=B6=84=ED=95=A0=EA=B3=BC=20code=20fence?= =?UTF-8?q?=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/discordMessageSplitter.ts | 246 +++++++++++++++++- .../discordMessageSplitter.test.ts | 243 +++++++++++++++++ tests/reportChannels/reportChannel.test.ts | 45 ++++ 3 files changed, 533 insertions(+), 1 deletion(-) diff --git a/src/reportChannels/discordMessageSplitter.ts b/src/reportChannels/discordMessageSplitter.ts index 13776eb..03775b7 100644 --- a/src/reportChannels/discordMessageSplitter.ts +++ b/src/reportChannels/discordMessageSplitter.ts @@ -7,11 +7,14 @@ const PAIR_SECTION_HEADINGS = new Set([ "### Confirmed Conflicts", "### Potential Risks" ]) +const SUGGESTED_PATCH_HEADING = "- Suggested Patch:" const PAIR_UNIT_BOUNDARIES = new Set([ "- AI Analysis:", "- Recommended Resolution:", - "- Suggested Patch:" + SUGGESTED_PATCH_HEADING ]) +const PATCH_LINE_PREFIX = " - " +const PATCH_FENCE_INDENT = " " // Discord 전송 내용과 branch 조합별 조각 위치 export type DiscordMessage = { @@ -25,6 +28,24 @@ type DiscordMessageBlock = { pairLabel?: string } +type SuggestedPatch = { + descriptionLine: string + openingFenceLine: string + bodyLines: string[] + closingFenceLine: string +} + +type SuggestedPatchBlock = { + pairHeading: string + pairLabel: string + patches: SuggestedPatch[] +} + +type SuggestedPatchFragment = { + patch: SuggestedPatch + bodyLines: string[] +} + // report 구조를 인식하면 section과 branch 조합 단위로, 아니면 길이만으로 분할 export function splitDiscordMessages(markdown: string): DiscordMessage[] { const blocks = structuralBlocksFor(markdown) @@ -112,6 +133,12 @@ function appendBlock( // 한 구조 block을 Discord 길이 제한에 맞는 message로 변환 function messagesFor(block: DiscordMessageBlock): DiscordMessage[] { + const suggestedPatchMessages = suggestedPatchMessagesFor(block) + + if (suggestedPatchMessages) { + return suggestedPatchMessages + } + const continuationHeading = continuationHeadingFor(block.pairLabel) return contentsFor( @@ -124,6 +151,223 @@ function messagesFor(block: DiscordMessageBlock): DiscordMessage[] { })) } +// 긴 Suggested Patch를 순번이 있고 code fence가 닫힌 message로 분할 +function suggestedPatchMessagesFor( + block: DiscordMessageBlock +): DiscordMessage[] | undefined { + if (block.lines.join("\n").length <= DISCORD_CONTENT_LIMIT) { + return undefined + } + + const suggestedPatchBlock = suggestedPatchBlockFor(block) + + if (!suggestedPatchBlock) { + return undefined + } + + let totalWidth = 1 + + while (true) { + const totalPlaceholder = "9".repeat(totalWidth) + const fragments = suggestedPatchFragmentsFor( + suggestedPatchBlock, + totalPlaceholder + ) + + if (!fragments) { + return undefined + } + + const total = fragments.length.toString() + + if (total.length === totalWidth) { + return fragments.map((fragment, index) => ({ + content: suggestedPatchContentFor( + suggestedPatchBlock.pairHeading, + fragment, + (index + 1).toString(), + total + ), + pairLabel: suggestedPatchBlock.pairLabel, + fragmentNumber: index + 1 + })) + } + + totalWidth = total.length + } +} + +// formatter가 만든 file 설명과 diff fence 묶음을 순서대로 해석 +function suggestedPatchBlockFor( + block: DiscordMessageBlock +): SuggestedPatchBlock | undefined { + if (!block.pairLabel) { + return undefined + } + + const pairHeading = `${PAIR_HEADING_PREFIX}${block.pairLabel}` + const [heading, suggestedPatchHeading, ...lines] = block.lines + + if ( + heading !== pairHeading || + suggestedPatchHeading !== SUGGESTED_PATCH_HEADING + ) { + return undefined + } + + const patches: SuggestedPatch[] = [] + let index = 0 + + while (index < lines.length) { + const descriptionLine = lines[index] + + if (descriptionLine === "") { + if (lines.slice(index).some(line => line !== "")) { + return undefined + } + + break + } + + const openingFenceLine = lines[index + 1] + const fence = patchFenceFor(openingFenceLine) + + if (!descriptionLine?.startsWith(PATCH_LINE_PREFIX) || !fence) { + return undefined + } + + const closingFenceLine = `${PATCH_FENCE_INDENT}${fence}` + const closingFenceIndex = lines.indexOf(closingFenceLine, index + 2) + + if (closingFenceIndex < 0) { + return undefined + } + + patches.push({ + descriptionLine, + openingFenceLine, + bodyLines: lines.slice(index + 2, closingFenceIndex), + closingFenceLine + }) + index = closingFenceIndex + 1 + } + + if (!patches.length) { + return undefined + } + + return { + pairHeading, + pairLabel: block.pairLabel, + patches + } +} + +// opening fence에서 formatter가 선택한 backtick 문자열을 추출 +function patchFenceFor(value: string | undefined): string | undefined { + if (!value?.startsWith(PATCH_FENCE_INDENT)) { + return undefined + } + + return /^(`{3,})diff$/.exec(value.slice(PATCH_FENCE_INDENT.length))?.[1] +} + +// 전체 fragment 수 자릿수에 맞춰 patch body를 길이 제한 안에서 분배 +function suggestedPatchFragmentsFor( + block: SuggestedPatchBlock, + total: string +): SuggestedPatchFragment[] | undefined { + const fragments: SuggestedPatchFragment[] = [] + + for (const patch of block.patches) { + if (!patch.bodyLines.length) { + const fragment = { patch, bodyLines: [] } + const content = suggestedPatchContentFor( + block.pairHeading, + fragment, + (fragments.length + 1).toString(), + total + ) + + if (DISCORD_CONTENT_LIMIT < content.length) { + return undefined + } + + fragments.push(fragment) + continue + } + + const remainingLines = [...patch.bodyLines] + let lineIndex = 0 + + while (lineIndex < remainingLines.length) { + const bodyLines: string[] = [] + const fragmentNumber = (fragments.length + 1).toString() + + while (lineIndex < remainingLines.length) { + const line = remainingLines[lineIndex] ?? "" + const fragment = { + patch, + bodyLines: [...bodyLines, line] + } + const content = suggestedPatchContentFor( + block.pairHeading, + fragment, + fragmentNumber, + total + ) + + if (content.length <= DISCORD_CONTENT_LIMIT) { + bodyLines.push(line) + lineIndex += 1 + continue + } + + if (bodyLines.length) { + break + } + + const emptyBodyContent = suggestedPatchContentFor( + block.pairHeading, + { patch, bodyLines: [""] }, + fragmentNumber, + total + ) + const availableLength = DISCORD_CONTENT_LIMIT - emptyBodyContent.length + + if (availableLength < 1) { + return undefined + } + + bodyLines.push(line.slice(0, availableLength)) + remainingLines[lineIndex] = line.slice(availableLength) + break + } + + fragments.push({ patch, bodyLines }) + } + } + + return fragments +} + +// 각 patch 조각에 조합 제목, 순번, 원래 fence를 함께 구성 +function suggestedPatchContentFor( + pairHeading: string, + fragment: SuggestedPatchFragment, + fragmentNumber: string, + total: string +): string { + return [ + pairHeading, + `- Suggested Patch (${fragmentNumber}/${total}):`, + fragment.patch.descriptionLine, + fragment.patch.openingFenceLine, + ...fragment.bodyLines, + fragment.patch.closingFenceLine + ].join("\n") +} + // 같은 branch 조합의 message에 입력 순서대로 연속된 조각 번호를 부여 function numberedPairMessagesFor(messages: DiscordMessage[]): DiscordMessage[] { const fragmentNumbers = new Map() diff --git a/tests/reportChannels/discordMessageSplitter.test.ts b/tests/reportChannels/discordMessageSplitter.test.ts index 8993839..e96e21e 100644 --- a/tests/reportChannels/discordMessageSplitter.test.ts +++ b/tests/reportChannels/discordMessageSplitter.test.ts @@ -202,3 +202,246 @@ test("repeats the pair heading for continued AI fragments", () => { pairMessages.map((_, index) => index + 1) ) }) + +// 긴 Suggested Patch에 순번을 붙이고 모든 message의 fence를 닫는지 확인 +test("numbers long suggested patch fragments and closes every code fence", () => { + const patchLines = Array.from({ length: 500 }, (_, index) => + ` +const value${index.toString()} = "${"x".repeat(24)}"` + ) + const messages = splitDiscordMessages(suggestedPatchReportFor([ + { + descriptionLine: " - `src/a.ts`: resolve conflict", + openingFenceLine: " ```diff", + patchLines, + closingFenceLine: " ```" + } + ])) + const patches = messages.filter(message => + message.content.includes("Suggested Patch (") + ) + + assert.equal(9 < patches.length, true) + assert.equal(patches.every(message => message.content.length <= 2000), true) + assert.deepEqual( + patches.map(message => + message.content.match(/Suggested Patch \((\d+)\/(\d+)\)/)?.[1] + ), + patches.map((_, index) => (index + 1).toString()) + ) + assert.equal( + patches.every(message => + message.content.match(/Suggested Patch \(\d+\/(\d+)\)/)?.[1] === + patches.length.toString() + ), + true + ) + assert.equal( + patches.every(message => hasClosedPatchFence(message.content)), + true + ) + assert.deepEqual( + patches.flatMap(message => patchBodyLinesFor(message.content)), + patchLines + ) +}) + +// Suggested Patch block이 정확히 2,000자면 유지하고 2,001자면 분할하는지 확인 +test("splits suggested patch only after the 2000 character boundary", () => { + const exactMarkdown = suggestedPatchReportWithBlockLength(2000) + const overflowMarkdown = suggestedPatchReportWithBlockLength(2001) + const exactMessages = splitDiscordMessages(exactMarkdown) + .filter(message => message.content.includes("Suggested Patch")) + const overflowMessages = splitDiscordMessages(overflowMarkdown) + .filter(message => message.content.includes("Suggested Patch")) + + assert.equal(exactMessages.length, 1) + assert.equal(exactMessages[0]?.content.length, 2000) + assert.match(exactMessages[0]?.content ?? "", /- Suggested Patch:/) + assert.doesNotMatch(exactMessages[0]?.content ?? "", /Suggested Patch \(/) + assert.equal(overflowMessages.length, 2) + assert.deepEqual( + overflowMessages.map(message => + message.content.match(/Suggested Patch \((\d+)\/2\)/)?.[1] + ), + ["1", "2"] + ) + assert.equal( + overflowMessages.every(message => message.content.length <= 2000), + true + ) + assert.equal( + overflowMessages.reduce( + (count, message) => count + (message.content.match(/x/g) ?? []).length, + 0 + ), + (overflowMarkdown.match(/x/g) ?? []).length + ) +}) + +// 여러 file patch와 formatter가 선택한 긴 fence를 원래 순서대로 유지하는지 확인 +test("keeps multiple patch files and custom fences before the next pair", () => { + const firstPatchLines = Array.from({ length: 90 }, (_, index) => + ` +const first${index.toString()} = "${"a".repeat(20)}"` + ) + const secondPatchLines = [ + " ```diff", + ...Array.from({ length: 90 }, (_, index) => + ` +const second${index.toString()} = "${"b".repeat(20)}"` + ), + " ```" + ] + const markdown = [ + suggestedPatchReportFor([ + { + descriptionLine: " - `src/a.ts`: keep first change", + openingFenceLine: " ```diff", + patchLines: firstPatchLines, + closingFenceLine: " ```" + }, + { + descriptionLine: " - `src/b.ts`: keep embedded fence", + openingFenceLine: " ````diff", + patchLines: secondPatchLines, + closingFenceLine: " ````" + } + ]), + "", + "#### `feature/b` ↔ `main`", + "- status: `confirmed_conflict`" + ].join("\n") + const messages = splitDiscordMessages(markdown) + const patches = messages.filter(message => + message.content.includes("Suggested Patch (") + ) + const descriptions = patches.map(message => + message.content.split("\n").find(line => line.startsWith(" - `src/")) + ) + const secondPatchMessages = patches.filter(message => + message.content.includes("`src/b.ts`") + ) + const nextPairIndex = messages.findIndex(message => + message.content.includes("#### `feature/b` ↔ `main`") + ) + const lastPatchIndex = messages.map(message => + message.content.includes("Suggested Patch (") + ).lastIndexOf(true) + + assert.equal(descriptions.includes(" - `src/a.ts`: keep first change"), true) + assert.equal(descriptions.includes(" - `src/b.ts`: keep embedded fence"), true) + assert.equal( + descriptions.map(line => line?.includes("src/a.ts") ?? false).lastIndexOf(true) < + descriptions.findIndex(line => line?.includes("src/b.ts")), + true + ) + assert.equal( + secondPatchMessages.every(message => + message.content.includes(" ````diff") && + message.content.endsWith(" ````") + ), + true + ) + assert.equal(lastPatchIndex < nextPairIndex, true) +}) + +// 완결되지 않은 patch 구조는 순번 재구성 없이 기존 길이 분할로 처리하는지 확인 +test("falls back to generic splitting for an incomplete suggested patch", () => { + const markdown = [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- Suggested Patch:", + " - `src/a.ts`: missing closing fence", + " ```diff", + ` +${"z".repeat(2500)}` + ].join("\n") + const messages = splitDiscordMessages(markdown) + const pairMessages = messages.filter(message => message.pairLabel) + + assert.equal( + pairMessages.some(message => message.content.includes("Suggested Patch (")), + false + ) + assert.equal(pairMessages.every(message => message.content.length <= 2000), true) + assert.equal( + pairMessages.reduce( + (count, message) => count + (message.content.match(/z/g) ?? []).length, + 0 + ), + 2500 + ) +}) + +type PatchInput = { + descriptionLine: string + openingFenceLine: string + patchLines: string[] + closingFenceLine: string +} + +function suggestedPatchReportFor(patches: PatchInput[]): string { + return [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- Suggested Patch:", + ...patches.flatMap(patch => [ + patch.descriptionLine, + patch.openingFenceLine, + ...patch.patchLines, + patch.closingFenceLine + ]) + ].join("\n") +} + +function suggestedPatchReportWithBlockLength(length: number): string { + const lines = [ + "#### `feature/a` ↔ `main`", + "- Suggested Patch:", + " - `src/a.ts`: boundary", + " ```diff", + "", + " ```" + ] + const fixedLength = lines.join("\n").length + const patchLinePrefix = " +" + lines[4] = `${patchLinePrefix}${"x".repeat( + length - fixedLength - patchLinePrefix.length + )}` + + return [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + ...lines + ].join("\n") +} + +function hasClosedPatchFence(content: string): boolean { + const lines = content.split("\n") + const openingFenceIndex = lines.findIndex(line => /^ `{3,}diff$/.test(line)) + const openingFence = lines[openingFenceIndex]?.slice(4, -4) + + return openingFenceIndex >= 0 && + lines.at(-1) === ` ${openingFence ?? ""}` +} + +function patchBodyLinesFor(content: string): string[] { + const lines = content.split("\n") + const openingFenceIndex = lines.findIndex(line => /^ `{3,}diff$/.test(line)) + + return lines.slice(openingFenceIndex + 1, -1) +} diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index 6a1ff54..8b85a3c 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -140,6 +140,51 @@ test("splits long discord report into multiple messages", async () => { assert.equal(fetcher.requests[1]?.body.content.length, 1) }) +// 긴 Suggested Patch request마다 길이 제한과 닫힌 code fence를 유지하는지 확인 +test("sends long suggested patch in bounded closed-fence requests", async () => { + const fetcher = fetchSpy({}) + const patchLines = Array.from({ length: 300 }, (_, index) => + ` +const value${index.toString()} = "${"x".repeat(24)}"` + ) + const result = await sendMergeRiskReport({ + markdown: [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- Suggested Patch:", + " - `src/a.ts`: resolve conflict", + " ```diff", + ...patchLines, + " ```" + ].join("\n") + }, { + discordWebhookUrl: "https://discord.test/webhook", + fetch: fetcher + }) + const patchRequests = fetcher.requests.filter(request => + request.body.content.includes("Suggested Patch (") + ) + + assert.equal(result.ok, true) + assert.equal(1 < patchRequests.length, true) + assert.equal( + fetcher.requests.every(request => request.body.content.length <= 2000), + true + ) + assert.equal( + patchRequests.every(request => { + const lines = request.body.content.split("\n") + return lines.includes(" ```diff") && lines.at(-1) === " ```" + }), + true + ) +}) + // report section과 branch 조합을 원래 순서대로 모두 Discord에 전송하는지 확인 test("sends report sections and branch pairs in source order", async () => { const fetcher = fetchSpy({}) From f21c2c2016da485851c1214b152942ec3ef247f5 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:40:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20Discord=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9C=84=EC=B9=98=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/reportChannel.ts | 44 +++++--- tests/reportChannels/reportChannel.test.ts | 113 ++++++++++++++++++++- 2 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/reportChannels/reportChannel.ts b/src/reportChannels/reportChannel.ts index af889c4..06f71f9 100644 --- a/src/reportChannels/reportChannel.ts +++ b/src/reportChannels/reportChannel.ts @@ -4,7 +4,10 @@ import { type ReportChannelOptions, type ReportChannelResult } from "./types.js" -import { splitDiscordMessages } from "./discordMessageSplitter.js" +import { + splitDiscordMessages, + type DiscordMessage +} from "./discordMessageSplitter.js" // Discord webhook URL이 있으면 Discord channel로 보내고 없으면 stdout으로 fallback export async function send( @@ -61,8 +64,8 @@ async function sendDiscord( const fetcher = options.fetch ?? fetch const messages = splitDiscordMessages(markdown) - try { - for (const message of messages) { + for (const message of messages) { + try { const response = await fetcher(webhookUrl, { method: "POST", headers: { @@ -75,25 +78,36 @@ async function sendDiscord( return { ok: false, target: "discord", - errorMessage: `Discord webhook request failed with status ${response.status}` + errorMessage: `${failureLocationFor(message)} failed with status ${response.status}` } } + } catch (error) { + return { + ok: false, + target: "discord", + errorMessage: `${failureLocationFor(message)} failed: ${ + redactedErrorMessageFor(error, webhookUrl) + }` + } } + } - return { - ok: true, - target: "discord", - messageCount: messages.length - } - } catch (error) { - return { - ok: false, - target: "discord", - errorMessage: redactedErrorMessageFor(error, webhookUrl) - } + return { + ok: true, + target: "discord", + messageCount: messages.length } } +// 실패한 Discord request를 branch 조합 또는 report 조각 위치로 표시 +function failureLocationFor(message: DiscordMessage): string { + if (message.pairLabel) { + return `Discord webhook request for pair ${message.pairLabel} fragment ${message.fragmentNumber}` + } + + return `Discord webhook request for report fragment ${message.fragmentNumber}` +} + // unknown error를 report 가능한 문자열로 변환 function errorMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index 8b85a3c..9f55283 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -96,7 +96,7 @@ test("returns failure when discord webhook responds with error", async () => { assert.deepEqual(result, { ok: false, target: "discord", - errorMessage: "Discord webhook request failed with status 500" + errorMessage: "Discord webhook request for report fragment 1 failed with status 500" }) }) @@ -111,6 +111,65 @@ test("redacts discord webhook url from thrown errors", async () => { assert.equal(result.ok, false) assert.equal(result.target, "discord") + assert.match( + result.ok ? "" : result.errorMessage, + /^Discord webhook request for report fragment 1 failed:/ + ) + assert.match( + result.ok ? "" : result.errorMessage, + /\[REDACTED_DISCORD_WEBHOOK_URL\]/ + ) + assert.doesNotMatch( + result.ok ? "" : result.errorMessage, + /secret-token/ + ) +}) + +// 중간 request 실패에 branch 조합과 연속된 조각 번호를 포함하는지 확인 +test("reports the branch pair and fragment number for a middle webhook failure", async () => { + const fetcher = fetchSequenceSpy([ + { ok: true, status: 204 }, + { ok: true, status: 204 }, + { ok: false, status: 500 }, + { ok: true, status: 204 } + ]) + const result = await sendMergeRiskReport({ + markdown: reportWithTwoPairFragments() + }, { + discordWebhookUrl: "https://discord.test/webhook", + fetch: fetcher + }) + + assert.deepEqual(result, { + ok: false, + target: "discord", + errorMessage: "Discord webhook request for pair `feature/a` ↔ `main` fragment 2 failed with status 500" + }) + assert.equal(fetcher.requests.length, 3) +}) + +// fetch 예외에도 실패 조각 문맥을 유지하면서 webhook URL을 제거하는지 확인 +test("reports the pair fragment and redacts webhook url from a fetch error", async () => { + const webhookUrl = "https://discord.test/secret-token" + const fetcher = fetchSequenceSpy([ + { ok: true, status: 204 }, + { ok: true, status: 204 }, + new Error(`request failed for ${webhookUrl}`), + { ok: true, status: 204 } + ]) + const result = await sendMergeRiskReport({ + markdown: reportWithTwoPairFragments() + }, { + discordWebhookUrl: webhookUrl, + fetch: fetcher + }) + + assert.equal(result.ok, false) + assert.equal(result.target, "discord") + assert.match( + result.ok ? "" : result.errorMessage, + /^Discord webhook request for pair `feature\/a` ↔ `main` fragment 2 failed:/ + ) assert.match( result.ok ? "" : result.errorMessage, /\[REDACTED_DISCORD_WEBHOOK_URL\]/ @@ -119,6 +178,7 @@ test("redacts discord webhook url from thrown errors", async () => { result.ok ? "" : result.errorMessage, /secret-token/ ) + assert.equal(fetcher.requests.length, 3) }) // Discord message length 제한을 넘는 report를 여러 메시지로 나눠 보내는지 확인 @@ -320,6 +380,41 @@ function fetchSpy( return spy } +function fetchSequenceSpy( + results: Array<{ + ok: boolean + status: number + } | Error> +): FetchSpy { + const requests: FetchSpy["requests"] = [] + const spy = (async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)) as { content: string } + }) + const result = results[requests.length - 1] + + if (!result) { + throw new Error("Unexpected fetch request") + } + + if (result instanceof Error) { + throw result + } + + return { + ok: result.ok, + status: result.status + } as Response + }) as FetchSpy + + spy.requests = requests + return spy +} + function failingFetch(message: string): typeof fetch { return (async (): Promise => { throw new Error(message) @@ -352,3 +447,19 @@ class DeferredFetchSpy { function nextEventLoopTurn(): Promise { return new Promise(resolve => setImmediate(resolve)) } + +function reportWithTwoPairFragments(): string { + return [ + "## Merge Risk Report", + "", + "### Summary", + "- watched branches: 1", + "", + "### Confirmed Conflicts", + "", + "#### `feature/a` ↔ `main`", + "- status: `confirmed_conflict`", + "- AI Analysis:", + " - status: `predicted`" + ].join("\n") +} From 3cef37e9c70da5b242fff91618b7e5ddec69bfa6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:04:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20Discord=20report=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/reportChannels/reportChannel.test.ts | 72 +++++++------------ .../branchPairMarkdownFormatter.test.ts | 49 +++++++++++++ 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index 9f55283..a8d9860 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -127,12 +127,14 @@ test("redacts discord webhook url from thrown errors", async () => { // 중간 request 실패에 branch 조합과 연속된 조각 번호를 포함하는지 확인 test("reports the branch pair and fragment number for a middle webhook failure", async () => { - const fetcher = fetchSequenceSpy([ - { ok: true, status: 204 }, - { ok: true, status: 204 }, - { ok: false, status: 500 }, - { ok: true, status: 204 } - ]) + const fetcher = fetchSpy({}, { + outcomes: [ + { ok: true, status: 204 }, + { ok: true, status: 204 }, + { ok: false, status: 500 }, + { ok: true, status: 204 } + ] + }) const result = await sendMergeRiskReport({ markdown: reportWithTwoPairFragments() }, { @@ -151,12 +153,14 @@ test("reports the branch pair and fragment number for a middle webhook failure", // fetch 예외에도 실패 조각 문맥을 유지하면서 webhook URL을 제거하는지 확인 test("reports the pair fragment and redacts webhook url from a fetch error", async () => { const webhookUrl = "https://discord.test/secret-token" - const fetcher = fetchSequenceSpy([ - { ok: true, status: 204 }, - { ok: true, status: 204 }, - new Error(`request failed for ${webhookUrl}`), - { ok: true, status: 204 } - ]) + const fetcher = fetchSpy({}, { + outcomes: [ + { ok: true, status: 204 }, + { ok: true, status: 204 }, + new Error(`request failed for ${webhookUrl}`), + { ok: true, status: 204 } + ] + }) const result = await sendMergeRiskReport({ markdown: reportWithTwoPairFragments() }, { @@ -357,6 +361,10 @@ function fetchSpy( options: { ok?: boolean status?: number + outcomes?: Array<{ + ok: boolean + status: number + } | Error> } = {} ): FetchSpy { const requests: FetchSpy["requests"] = [] @@ -368,46 +376,20 @@ function fetchSpy( url: String(input), body: JSON.parse(String(init?.body)) as { content: string } }) + const outcome = options.outcomes?.[requests.length - 1] - return { - ok: options.ok ?? true, - status: options.status ?? 204, - json: async () => body - } as Response - }) as FetchSpy - - spy.requests = requests - return spy -} - -function fetchSequenceSpy( - results: Array<{ - ok: boolean - status: number - } | Error> -): FetchSpy { - const requests: FetchSpy["requests"] = [] - const spy = (async ( - input: string | URL | Request, - init?: RequestInit - ): Promise => { - requests.push({ - url: String(input), - body: JSON.parse(String(init?.body)) as { content: string } - }) - const result = results[requests.length - 1] - - if (!result) { + if (options.outcomes && !outcome) { throw new Error("Unexpected fetch request") } - if (result instanceof Error) { - throw result + if (outcome instanceof Error) { + throw outcome } return { - ok: result.ok, - status: result.status + ok: outcome?.ok ?? options.ok ?? true, + status: outcome?.status ?? options.status ?? 204, + json: async () => body } as Response }) as FetchSpy diff --git a/tests/reports/branchPairMarkdownFormatter.test.ts b/tests/reports/branchPairMarkdownFormatter.test.ts index 8fc5276..810235c 100644 --- a/tests/reports/branchPairMarkdownFormatter.test.ts +++ b/tests/reports/branchPairMarkdownFormatter.test.ts @@ -8,6 +8,9 @@ import { type BranchPairMergeRiskReport, type BranchPairMergeRiskReportPairItem } from "../../src/index.js" +import { + splitDiscordMessages +} from "../../src/reportChannels/discordMessageSplitter.js" // 활성 기간과 branch 및 조합 수를 Summary에 표시 test("formats branch pair report summary", () => { @@ -50,6 +53,52 @@ test("formats confirmed conflict details and suggested patch", () => { assert.equal(markdown.match(/#### `feature\/a` ↔ `main`/g)?.length, 1) }) +// formatter의 실제 Markdown 구조를 Discord message splitter가 같은 계약으로 해석하는지 확인 +test("formats report compatible with discord message splitting", () => { + const value = report() + const analysis = value.confirmedConflicts[0]?.aiAnalysis + + if (analysis?.status !== "predicted" || analysis.response.kind !== "confirmed_conflict") { + assert.fail("Expected predicted confirmed conflict analysis") + } + + analysis.response.patches = [{ + ...analysis.response.patches[0]!, + patch: Array.from({ length: 300 }, (_, index) => + `+const value${index.toString()} = "${"x".repeat(24)}"` + ).join("\n") + }] + + const markdown = formatBranchPairMergeRiskReportMarkdown(value) + const messages = splitDiscordMessages(markdown) + const pairLabel = "`feature/a` ↔ `main`" + const pairMessages = messages.filter(message => message.pairLabel === pairLabel) + const patchMessages = pairMessages.filter(message => + message.content.includes("- Suggested Patch (") + ) + + assert.match(messages[0]?.content ?? "", /### Summary/) + assert.equal( + pairMessages.some(message => + message.content.startsWith(`#### ${pairLabel}\n- AI Analysis:`) + ), + true + ) + assert.equal( + pairMessages.some(message => + message.content.startsWith(`#### ${pairLabel}\n- Recommended Resolution:`) + ), + true + ) + assert.equal(1 < patchMessages.length, true) + assert.equal(patchMessages.every(message => message.content.endsWith(" ```")), true) + assert.deepEqual( + pairMessages.map(message => message.fragmentNumber), + pairMessages.map((_, index) => index + 1) + ) + assert.equal(messages.every(message => message.content.length <= 2000), true) +}) + // 잠재 위험의 AI 해결 순서와 예방 조치를 표시 test("formats potential risk analysis and preventive actions", () => { const markdown = formatBranchPairMergeRiskReportMarkdown(report())