From 52e0e614b1b557b54fc79ee598ba623a3ceb2d13 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:08:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20GitHub=20Actions=20summary=EC=99=80?= =?UTF-8?q?=20report=20=EB=8F=99=EC=8B=9C=20=EC=A0=84=EB=8B=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/reportChannel.ts | 88 +++++++++++++++++ src/workflows/mergeRiskWatch.ts | 7 +- tests/reportChannels/reportChannel.test.ts | 107 +++++++++++++++++++++ tests/workflows/mergeRiskWatch.test.ts | 17 +++- 4 files changed, 214 insertions(+), 5 deletions(-) diff --git a/src/reportChannels/reportChannel.ts b/src/reportChannels/reportChannel.ts index 06f71f9..17465ec 100644 --- a/src/reportChannels/reportChannel.ts +++ b/src/reportChannels/reportChannel.ts @@ -1,3 +1,4 @@ +import { appendFile } from "node:fs/promises" import { DISCORD_WEBHOOK_URL_ENV_NAME, type ReportChannelInput, @@ -9,6 +10,72 @@ import { type DiscordMessage } from "./discordMessageSplitter.js" +const GITHUB_STEP_SUMMARY_ENV_NAME = "GITHUB_STEP_SUMMARY" + +type AppendFileOperation = ( + path: string, + content: string, + encoding: BufferEncoding +) => Promise + +type WorkflowReportDeliveryOptions = ReportChannelOptions & { + githubStepSummaryPath?: string + appendFile?: AppendFileOperation +} + +type WorkflowReportDeliveryResult = + | { ok: true } + | { + ok: false + errorMessage: string + } + +// workflow에서는 Actions summary 또는 stdout을 기본으로 기록하고 Discord를 추가 전송 +export async function deliver( + input: ReportChannelInput, + options: WorkflowReportDeliveryOptions = {} +): Promise { + if (!input.markdown.trim()) { + return { + ok: true + } + } + + const summaryPath = options.githubStepSummaryPath ?? + process.env[GITHUB_STEP_SUMMARY_ENV_NAME] + const outputResult = summaryPath?.trim() + ? await sendGitHubActionsSummary(input.markdown, summaryPath, options) + : await sendStdout(input.markdown, options) + + if (!outputResult.ok) { + return { + ok: false, + errorMessage: outputResult.errorMessage + } + } + + const webhookUrl = options.discordWebhookUrl ?? process.env[DISCORD_WEBHOOK_URL_ENV_NAME] + + if (!webhookUrl) { + return { + ok: true + } + } + + const discordResult = await sendDiscord(input.markdown, webhookUrl, options) + + if (!discordResult.ok) { + return { + ok: false, + errorMessage: discordResult.errorMessage + } + } + + return { + ok: true + } +} + // Discord webhook URL이 있으면 Discord channel로 보내고 없으면 stdout으로 fallback export async function send( input: ReportChannelInput, @@ -55,6 +122,27 @@ async function sendStdout( } } +// GitHub Actions가 제공한 step summary 파일에 전체 Markdown report를 추가 기록 +async function sendGitHubActionsSummary( + markdown: string, + path: string, + options: WorkflowReportDeliveryOptions +): Promise { + try { + const write = options.appendFile ?? appendFile + await write(path, `${markdown}\n`, "utf8") + + return { + ok: true + } + } catch (error) { + return { + ok: false, + errorMessage: `GitHub Actions summary write failed: ${errorMessageFor(error)}` + } + } +} + // Discord content 제한을 넘지 않도록 Markdown report를 여러 메시지로 나눠 전송 async function sendDiscord( markdown: string, diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 843c4e0..3e943df 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -18,7 +18,7 @@ import { build as buildAiPredictionPairRequests } from "../ai/predictionPairRequ import { predict as predictBranchPairsWithAi } from "../ai/predictionPairRunner.js" import { build as buildBranchPairMergeRiskReport } from "../reports/branchPairReportBuilder.js" import { format as formatBranchPairMergeRiskReportMarkdown } from "../reports/branchPairMarkdownFormatter.js" -import { send as sendMergeRiskReport } from "../reportChannels/reportChannel.js" +import { deliver as deliverMergeRiskReport } from "../reportChannels/reportChannel.js" import { sanitizeAiPredictionPairFailureDebugEvent, sanitizeAiPredictionPairPromptDebugEvent, @@ -51,6 +51,7 @@ type MergeRiskWatchOptions = { debugArtifactDir?: string workflowRef?: string fetch?: typeof fetch + reportDeliveryOptions?: Parameters[1] } type RemoteBranchLine = { @@ -243,9 +244,9 @@ export async function run(options: MergeRiskWatchOptions): Promise { aiResults: predictions }) const markdown = formatBranchPairMergeRiskReportMarkdown(report) - const result = await sendMergeRiskReport({ + const result = await deliverMergeRiskReport({ markdown - }) + }, options.reportDeliveryOptions) if (!result.ok) { throw new Error(result.errorMessage) diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index a8d9860..7f07114 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -3,6 +3,99 @@ import assert from "node:assert/strict" import { sendMergeRiskReport } from "../../src/index.js" +import { + deliver as deliverMergeRiskReport +} from "../../src/reportChannels/reportChannel.js" + +// GitHub Actions에서는 전체 Markdown report를 step summary에 기록하는지 확인 +test("writes full report to GitHub Actions summary", async () => { + const appendFile = new AppendFileSpy() + const stdout = new StdoutSpy() + const markdown = reportWithTwoPairFragments() + const result = await deliverMergeRiskReport({ + markdown + }, { + discordWebhookUrl: "", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: appendFile.write, + stdout + }) + + assert.deepEqual(result, { + ok: true + }) + assert.deepEqual(appendFile.writes, [{ + path: "/tmp/github-step-summary", + content: `${markdown}\n` + }]) + assert.equal(stdout.output, "") +}) + +// Discord 설정이 있어도 Actions summary와 Discord에 report를 모두 전달하는지 확인 +test("writes Actions summary and sends Discord report", async () => { + const appendFile = new AppendFileSpy() + const fetcher = fetchSpy({}) + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: appendFile.write, + fetch: fetcher + }) + + assert.deepEqual(result, { + ok: true + }) + assert.deepEqual(appendFile.writes, [{ + path: "/tmp/github-step-summary", + content: "## Merge Risk Report\n" + }]) + assert.equal(fetcher.requests.length, 1) +}) + +// Actions summary 경로가 없으면 stdout과 설정된 Discord에 모두 전달하는지 확인 +test("falls back to stdout and sends Discord report outside Actions", async () => { + const fetcher = fetchSpy({}) + const stdout = new StdoutSpy() + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "", + fetch: fetcher, + stdout + }) + + assert.deepEqual(result, { + ok: true + }) + assert.equal(stdout.output, "## Merge Risk Report\n") + assert.equal(fetcher.requests.length, 1) +}) + +// 빈 Markdown report는 Actions summary, stdout, Discord 어디에도 쓰지 않는지 확인 +test("skips every workflow report delivery for empty report", async () => { + const appendFile = new AppendFileSpy() + const fetcher = fetchSpy({}) + const stdout = new StdoutSpy() + const result = await deliverMergeRiskReport({ + markdown: " \n\t" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: appendFile.write, + fetch: fetcher, + stdout + }) + + assert.deepEqual(result, { + ok: true + }) + assert.deepEqual(appendFile.writes, []) + assert.equal(fetcher.requests.length, 0) + assert.equal(stdout.output, "") +}) // Discord webhook URL이 없으면 Markdown report를 stdout으로 출력하는지 확인 test("sends report to stdout when discord webhook is missing", async () => { @@ -347,6 +440,20 @@ class StdoutSpy { } } +class AppendFileSpy { + readonly writes: Array<{ + path: string + content: string + }> = [] + + readonly write = async (path: string, content: string): Promise => { + this.writes.push({ + path, + content + }) + } +} + type FetchSpy = typeof fetch & { requests: Array<{ url: string diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index d351c36..2e60924 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -216,6 +216,7 @@ test("predicts confirmed conflict pair once", async () => { const fixture = await createWorkflowGitFixture({ peerContent: "peer critical content\n" }) + const summaryPath = join(fixture.debugArtifactDir, "summary.md") const originalFetch = globalThis.fetch const originalOpenAiApiKey = process.env.OPENAI_API_KEY const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL @@ -247,16 +248,22 @@ test("predicts confirmed conflict pair once", async () => { githubToken: undefined, repositoryPath: fixture.repositoryPath, baseBranch: "main", - debugArtifactDir: fixture.debugArtifactDir + debugArtifactDir: fixture.debugArtifactDir, + reportDeliveryOptions: { + githubStepSummaryPath: summaryPath + } }) const resultArtifact = await readFile( join(fixture.debugArtifactDir, "ai-result.json"), "utf8" ) + const summaryReport = await readFile(summaryPath, "utf8") assert.equal(openAiRequestCount, 1) assert.match(discordReport, /resolved critical content/) + assert.match(summaryReport, /`feature\/critical` ↔ `feature\/critical-peer`/) + assert.match(summaryReport, /resolved critical content/) assert.doesNotMatch(resultArtifact, /resolved critical content/) assert.equal((await readdir(fixture.debugArtifactDir)).includes("report.md"), false) } finally { @@ -544,7 +551,13 @@ function baseOptions() { baseBranch: "develop", remoteName: "origin", githubApiUrl: "https://api.github.test", - githubToken: "github-token" + githubToken: "github-token", + reportDeliveryOptions: { + githubStepSummaryPath: "", + stdout: { + write: () => true + } + } } } From f734bc7b804d9909805bf5025eb18a617f1dc4ec Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:17:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20report=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B2=A9=EB=A6=AC=EC=99=80=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/reportChannels/reportChannel.ts | 47 +++++++--- tests/reportChannels/reportChannel.test.ts | 102 +++++++++++++++++++++ tests/workflows/mergeRiskWatch.test.ts | 52 +++++++++++ 3 files changed, 187 insertions(+), 14 deletions(-) diff --git a/src/reportChannels/reportChannel.ts b/src/reportChannels/reportChannel.ts index 17465ec..c07a250 100644 --- a/src/reportChannels/reportChannel.ts +++ b/src/reportChannels/reportChannel.ts @@ -41,33 +41,52 @@ export async function deliver( } } + const errorMessages: string[] = [] const summaryPath = options.githubStepSummaryPath ?? process.env[GITHUB_STEP_SUMMARY_ENV_NAME] - const outputResult = summaryPath?.trim() - ? await sendGitHubActionsSummary(input.markdown, summaryPath, options) - : await sendStdout(input.markdown, options) - if (!outputResult.ok) { - return { - ok: false, - errorMessage: outputResult.errorMessage + if (summaryPath?.trim()) { + const summaryResult = await sendGitHubActionsSummary( + input.markdown, + summaryPath, + options + ) + + if (!summaryResult.ok) { + errorMessages.push(summaryResult.errorMessage) + + const stdoutResult = await sendStdout(input.markdown, options) + + if (!stdoutResult.ok) { + errorMessages.push( + `stdout report write failed: ${stdoutResult.errorMessage}` + ) + } + } + } else { + const stdoutResult = await sendStdout(input.markdown, options) + + if (!stdoutResult.ok) { + errorMessages.push( + `stdout report write failed: ${stdoutResult.errorMessage}` + ) } } const webhookUrl = options.discordWebhookUrl ?? process.env[DISCORD_WEBHOOK_URL_ENV_NAME] - if (!webhookUrl) { - return { - ok: true + if (webhookUrl) { + const discordResult = await sendDiscord(input.markdown, webhookUrl, options) + + if (!discordResult.ok) { + errorMessages.push(discordResult.errorMessage) } } - const discordResult = await sendDiscord(input.markdown, webhookUrl, options) - - if (!discordResult.ok) { + if (0 < errorMessages.length) { return { ok: false, - errorMessage: discordResult.errorMessage + errorMessage: errorMessages.join("\n") } } diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index 7f07114..da9c3b4 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -97,6 +97,108 @@ test("skips every workflow report delivery for empty report", async () => { assert.equal(stdout.output, "") }) +// Actions summary 기록이 실패해도 stdout fallback과 Discord 전송을 계속하는지 확인 +test("continues stdout and Discord delivery after summary failure", async () => { + const fetcher = fetchSpy({}) + const stdout = new StdoutSpy() + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: async () => { + throw new Error("summary unavailable") + }, + fetch: fetcher, + stdout + }) + + assert.deepEqual(result, { + ok: false, + errorMessage: "GitHub Actions summary write failed: summary unavailable" + }) + assert.equal(stdout.output, "## Merge Risk Report\n") + assert.equal(fetcher.requests.length, 1) +}) + +// Discord 전송이 실패해도 Actions summary에 전체 report가 남는지 확인 +test("keeps Actions summary when Discord delivery fails", async () => { + const appendFile = new AppendFileSpy() + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: appendFile.write, + fetch: fetchSpy({}, { + ok: false, + status: 500 + }) + }) + + assert.deepEqual(result, { + ok: false, + errorMessage: "Discord webhook request for report fragment 1 failed with status 500" + }) + assert.deepEqual(appendFile.writes, [{ + path: "/tmp/github-step-summary", + content: "## Merge Risk Report\n" + }]) +}) + +// Actions summary와 Discord가 모두 실패하면 두 channel 오류를 순서대로 반환하는지 확인 +test("reports summary and Discord delivery failures together", async () => { + const fetcher = fetchSpy({}, { + ok: false, + status: 500 + }) + const stdout = new StdoutSpy() + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: async () => { + throw new Error("summary unavailable") + }, + fetch: fetcher, + stdout + }) + + assert.deepEqual(result, { + ok: false, + errorMessage: [ + "GitHub Actions summary write failed: summary unavailable", + "Discord webhook request for report fragment 1 failed with status 500" + ].join("\n") + }) + assert.equal(stdout.output, "## Merge Risk Report\n") + assert.equal(fetcher.requests.length, 1) +}) + +// local stdout 실패가 설정된 Discord 전송을 막지 않는지 확인 +test("continues Discord delivery after stdout failure", async () => { + const fetcher = fetchSpy({}) + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "", + fetch: fetcher, + stdout: { + write: () => { + throw new Error("stdout unavailable") + } + } + }) + + assert.deepEqual(result, { + ok: false, + errorMessage: "stdout report write failed: stdout unavailable" + }) + assert.equal(fetcher.requests.length, 1) +}) + // Discord webhook URL이 없으면 Markdown report를 stdout으로 출력하는지 확인 test("sends report to stdout when discord webhook is missing", async () => { const stdout = new StdoutSpy() diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 2e60924..46e44d9 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -274,6 +274,58 @@ test("predicts confirmed conflict pair once", async () => { } }) +// Actions summary 기록 실패 후에도 stdout과 Discord report를 남기고 workflow를 실패시키는지 확인 +test("propagates summary failure after fallback report delivery", async () => { + const fixture = await createWorkflowGitFixture({ + separateHunks: true + }) + const originalFetch = globalThis.fetch + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + let discordRequestCount = 0 + let stdoutReport = "" + + delete process.env.OPENAI_API_KEY + process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async input => { + assert.equal(new Request(input).url, "https://discord.test/webhook-secret") + discordRequestCount += 1 + return new Response(null, { status: 204 }) + } + + try { + await assert.rejects( + run({ + ...baseOptions(), + githubToken: undefined, + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + reportDeliveryOptions: { + githubStepSummaryPath: join(fixture.debugArtifactDir, "summary.md"), + appendFile: async () => { + throw new Error("summary unavailable") + }, + stdout: { + write: value => { + stdoutReport += String(value) + return true + } + } + } + }), + /GitHub Actions summary write failed: summary unavailable/ + ) + + assert.match(stdoutReport, /## Merge Risk Report/) + assert.equal(0 < discordRequestCount, true) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + // provider 오류 원문이 실패 artifact에 기록되지 않는지 확인 test("redacts provider error detail from debug artifacts", async () => { const fixture = await createWorkflowGitFixture() From 5140f99f8d7437096f1df6b6b24df216fcfa7115 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:02:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20report=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/reportChannels/reportChannel.test.ts | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/reportChannels/reportChannel.test.ts b/tests/reportChannels/reportChannel.test.ts index da9c3b4..2c3d740 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -31,6 +31,36 @@ test("writes full report to GitHub Actions summary", async () => { assert.equal(stdout.output, "") }) +// 주입 경로가 없어도 Actions 환경 변수의 summary 경로를 사용하는지 확인 +test("reads GitHub Actions summary path from environment", async () => { + const appendFile = new AppendFileSpy() + const originalSummaryPath = process.env.GITHUB_STEP_SUMMARY + process.env.GITHUB_STEP_SUMMARY = "/tmp/github-step-summary-from-env" + + try { + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "", + appendFile: appendFile.write + }) + + assert.deepEqual(result, { + ok: true + }) + assert.deepEqual(appendFile.writes, [{ + path: "/tmp/github-step-summary-from-env", + content: "## Merge Risk Report\n" + }]) + } finally { + if (originalSummaryPath === undefined) { + delete process.env.GITHUB_STEP_SUMMARY + } else { + process.env.GITHUB_STEP_SUMMARY = originalSummaryPath + } + } +}) + // Discord 설정이 있어도 Actions summary와 Discord에 report를 모두 전달하는지 확인 test("writes Actions summary and sends Discord report", async () => { const appendFile = new AppendFileSpy() @@ -176,6 +206,39 @@ test("reports summary and Discord delivery failures together", async () => { assert.equal(fetcher.requests.length, 1) }) +// summary, stdout fallback, Discord가 모두 실패하면 오류를 전달 순서대로 반환하는지 확인 +test("reports summary stdout and Discord failures in order", async () => { + const fetcher = fetchSpy({}, { + ok: false, + status: 500 + }) + const result = await deliverMergeRiskReport({ + markdown: "## Merge Risk Report" + }, { + discordWebhookUrl: "https://discord.test/webhook", + githubStepSummaryPath: "/tmp/github-step-summary", + appendFile: async () => { + throw new Error("summary unavailable") + }, + fetch: fetcher, + stdout: { + write: () => { + throw new Error("stdout unavailable") + } + } + }) + + assert.deepEqual(result, { + ok: false, + errorMessage: [ + "GitHub Actions summary write failed: summary unavailable", + "stdout report write failed: stdout unavailable", + "Discord webhook request for report fragment 1 failed with status 500" + ].join("\n") + }) + assert.equal(fetcher.requests.length, 1) +}) + // local stdout 실패가 설정된 Discord 전송을 막지 않는지 확인 test("continues Discord delivery after stdout failure", async () => { const fetcher = fetchSpy({})