diff --git a/src/reportChannels/reportChannel.ts b/src/reportChannels/reportChannel.ts index 06f71f9..c07a250 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,91 @@ 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 errorMessages: string[] = [] + const summaryPath = options.githubStepSummaryPath ?? + process.env[GITHUB_STEP_SUMMARY_ENV_NAME] + + 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) { + const discordResult = await sendDiscord(input.markdown, webhookUrl, options) + + if (!discordResult.ok) { + errorMessages.push(discordResult.errorMessage) + } + } + + if (0 < errorMessages.length) { + return { + ok: false, + errorMessage: errorMessages.join("\n") + } + } + + return { + ok: true + } +} + // Discord webhook URL이 있으면 Discord channel로 보내고 없으면 stdout으로 fallback export async function send( input: ReportChannelInput, @@ -55,6 +141,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..2c3d740 100644 --- a/tests/reportChannels/reportChannel.test.ts +++ b/tests/reportChannels/reportChannel.test.ts @@ -3,6 +3,264 @@ 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, "") +}) + +// 주입 경로가 없어도 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() + 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, "") +}) + +// 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) +}) + +// 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({}) + 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 () => { @@ -347,6 +605,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..46e44d9 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 { @@ -267,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() @@ -544,7 +603,13 @@ function baseOptions() { baseBranch: "develop", remoteName: "origin", githubApiUrl: "https://api.github.test", - githubToken: "github-token" + githubToken: "github-token", + reportDeliveryOptions: { + githubStepSummaryPath: "", + stdout: { + write: () => true + } + } } }