From 818094c3d51b2abcc6fd01e64f80548b96695213 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:59:03 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20branch=20selection=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=82=AC=EC=9C=A0=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/branches/branchSelector.ts | 72 +++++++++++++++++++++------ src/branches/types.ts | 15 ++++++ tests/branches/branchSelector.test.ts | 25 +++++++++- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/branches/branchSelector.ts b/src/branches/branchSelector.ts index c696f67..744adcc 100644 --- a/src/branches/branchSelector.ts +++ b/src/branches/branchSelector.ts @@ -1,6 +1,9 @@ import type { BranchContext, + BranchExclusionReason, + BranchSelectionResult, BranchSelectionOptions, + ExcludedBranch, RepositoryBranch } from "./types.js" @@ -8,27 +11,64 @@ export function select( branches: RepositoryBranch[], options: BranchSelectionOptions ): BranchContext[] { - return branches - .filter(branch => isWatchableBranch(branch, options)) - .map(branch => ({ - baseBranch: options.baseBranch, - name: branch.name, - headSha: branch.sha, - author: branch.author, - updatedAt: branch.updatedAt, - checks: branch.checks ?? [], - pullRequest: branch.pullRequest - })) + return selectWithReasons(branches, options).selected } -function isWatchableBranch( +export function selectWithReasons( + branches: RepositoryBranch[], + options: BranchSelectionOptions +): BranchSelectionResult { + const selected: BranchContext[] = [] + const excluded: ExcludedBranch[] = [] + + for (const branch of branches) { + const reason = exclusionReasonFor(branch, options) + + if (reason) { + excluded.push({ + name: branch.name, + sha: branch.sha, + reason + }) + continue + } + + selected.push(branchContextFor(branch, options)) + } + + return { + selected, + excluded + } +} + +function branchContextFor( + branch: RepositoryBranch, + options: BranchSelectionOptions +): BranchContext { + return { + baseBranch: options.baseBranch, + name: branch.name, + headSha: branch.sha, + author: branch.author, + updatedAt: branch.updatedAt, + checks: branch.checks ?? [], + pullRequest: branch.pullRequest + } +} + +function exclusionReasonFor( branch: RepositoryBranch, options: BranchSelectionOptions -): boolean { +): BranchExclusionReason | undefined { // base, default branch는 비교 기준이므로 감시 대상에서 제외 - if (branch.name === options.baseBranch || branch.name === options.defaultBranch) { - return false + if (branch.name === options.baseBranch) { + return "base_branch" + } + + if (branch.name === options.defaultBranch) { + return "default_branch" } - return true + return undefined } diff --git a/src/branches/types.ts b/src/branches/types.ts index de0ff71..ef5e36a 100644 --- a/src/branches/types.ts +++ b/src/branches/types.ts @@ -25,6 +25,21 @@ export type BranchSelectionOptions = { defaultBranch?: string } +export type BranchExclusionReason = + | "base_branch" + | "default_branch" + +export type ExcludedBranch = { + name: string + sha: string + reason: BranchExclusionReason +} + +export type BranchSelectionResult = { + selected: BranchContext[] + excluded: ExcludedBranch[] +} + export type BranchContext = { baseBranch: string name: string diff --git a/tests/branches/branchSelector.test.ts b/tests/branches/branchSelector.test.ts index 8baaa7a..070a1d6 100644 --- a/tests/branches/branchSelector.test.ts +++ b/tests/branches/branchSelector.test.ts @@ -1,6 +1,6 @@ import test from "node:test" import assert from "node:assert/strict" -import { select } from "../../src/branches/branchSelector.js" +import { select, selectWithReasons } from "../../src/branches/branchSelector.js" import type { RepositoryBranch } from "../../src/branches/types.js" // base, default branch가 감시 대상에서 제외되는지 확인 @@ -17,6 +17,29 @@ test("excludes base and default branches", () => { assert.deepEqual(selected.map(branch => branch.name), ["feature/watch"]) }) +// base/default branch 제외 사유를 debug artifact에 남길 수 있는지 확인 +test("records branch exclusion reasons", () => { + const result = selectWithReasons([ + branch("main"), + branch("develop"), + branch("feature/watch") + ], { + baseBranch: "develop", + defaultBranch: "main" + }) + + assert.deepEqual(result.selected.map(branch => branch.name), ["feature/watch"]) + assert.deepEqual(result.excluded, [{ + name: "main", + sha: "main-sha", + reason: "default_branch" + }, { + name: "develop", + sha: "develop-sha", + reason: "base_branch" + }]) +}) + // base/default branch를 제외한 모든 branch가 감시 대상으로 유지되는지 확인 test("selects every non-base branch", () => { const selected = select([ From 75c691a36a7100f2b685754014909dd3c957f97e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:59:57 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20debug=20artifact=20writer=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/debug/debugArtifact.ts | 36 +++++++++++++++++ tests/debug/debugArtifact.test.ts | 64 +++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/debug/debugArtifact.ts create mode 100644 tests/debug/debugArtifact.test.ts diff --git a/src/debug/debugArtifact.ts b/src/debug/debugArtifact.ts new file mode 100644 index 0000000..92f8e2e --- /dev/null +++ b/src/debug/debugArtifact.ts @@ -0,0 +1,36 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" + +export type DebugArtifactWriter = { + writeJson(name: string, value: unknown): Promise + writeText(name: string, value: string): Promise +} + +export function writerFor(directory: string | undefined): DebugArtifactWriter | undefined { + return directory + ? new FileDebugArtifactWriter(directory) + : undefined +} + +class FileDebugArtifactWriter implements DebugArtifactWriter { + constructor(private readonly directory: string) {} + + async writeJson(name: string, value: unknown): Promise { + await this.writeText(name, `${JSON.stringify(value, jsonValueFor, 2)}\n`) + } + + async writeText(name: string, value: string): Promise { + await mkdir(this.directory, { + recursive: true + }) + await writeFile(join(this.directory, name), value, "utf8") + } +} + +function jsonValueFor(_key: string, value: unknown): unknown { + if (value instanceof Date) { + return value.toISOString() + } + + return value +} diff --git a/tests/debug/debugArtifact.test.ts b/tests/debug/debugArtifact.test.ts new file mode 100644 index 0000000..5f141a7 --- /dev/null +++ b/tests/debug/debugArtifact.test.ts @@ -0,0 +1,64 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { writerFor } from "../../src/debug/debugArtifact.js" + +// debug artifact writer가 JSON 파일을 안정적으로 생성하는지 확인 +test("writes debug artifact json", async () => { + const directory = await mkdtemp(join(tmpdir(), "watcher-debug-")) + + try { + const writer = writerFor(directory) + assert.ok(writer) + + await writer.writeJson("run.json", { + repository: "opficdev/Watcher", + generatedAt: new Date("2026-06-24T00:00:00.000Z") + }) + + assert.equal( + await readFile(join(directory, "run.json"), "utf8"), + [ + "{", + " \"repository\": \"opficdev/Watcher\",", + " \"generatedAt\": \"2026-06-24T00:00:00.000Z\"", + "}", + "" + ].join("\n") + ) + } finally { + await rm(directory, { + recursive: true, + force: true + }) + } +}) + +// debug artifact writer가 text 파일을 생성하는지 확인 +test("writes debug artifact text", async () => { + const directory = await mkdtemp(join(tmpdir(), "watcher-debug-")) + + try { + const writer = writerFor(directory) + assert.ok(writer) + + await writer.writeText("report.md", "## Merge Risk Report\n") + + assert.equal( + await readFile(join(directory, "report.md"), "utf8"), + "## Merge Risk Report\n" + ) + } finally { + await rm(directory, { + recursive: true, + force: true + }) + } +}) + +// directory가 없으면 artifact 기록을 비활성화하는지 확인 +test("omits writer when debug artifact directory is missing", () => { + assert.equal(writerFor(undefined), undefined) +}) From e55785bbd77fae9e589e17e025d7d687b6590bc4 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:49 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20AI=20prediction=20debug=20hook=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/ai/predictionRunner.ts | 47 ++++++++++++++++++++++++-- src/ai/types.ts | 32 ++++++++++++++++-- src/index.ts | 5 +++ tests/ai/predictionRunner.test.ts | 56 +++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/ai/predictionRunner.ts b/src/ai/predictionRunner.ts index 8bfd712..5052bc5 100644 --- a/src/ai/predictionRunner.ts +++ b/src/ai/predictionRunner.ts @@ -3,6 +3,7 @@ import { select as selectTargets } from "./predictionTargetSelector.js" import { validateBatch as validateBatchResponse } from "./predictionResponseValidator.js" import type { AiPrediction, + AiPredictionDebugTarget, AiPredictionClient, AiPredictionEvidencePayload, AiPredictionResult, @@ -36,16 +37,49 @@ async function predictedResultsFor( client: AiPredictionClient, options: AiPredictionRunOptions ): Promise> { + const targetBranches = targets.map(debugTargetFor) + const prompt = buildBatchPrompt(targets, options) + await options.debugObserver?.onPromptBuilt?.({ + targetBranches, + prompt + }) + + let response: unknown + + try { + response = await client.predict(prompt) + } catch (error) { + const errorMessage = errorMessageFor(error) + await options.debugObserver?.onPredictionFailed?.({ + targetBranches, + errorMessage + }) + + return new Map(targets.map(target => [ + target, + failedResultFor(target, errorMessage) + ])) + } + + await options.debugObserver?.onResponseReceived?.({ + targetBranches, + response + }) + try { - const prompt = buildBatchPrompt(targets, options) - const response = await client.predict(prompt) const predictions = validateBatchResponse(response) return resultsByPayloadFor(targets, predictions) } catch (error) { + const errorMessage = errorMessageFor(error) + await options.debugObserver?.onPredictionFailed?.({ + targetBranches, + errorMessage + }) + return new Map(targets.map(target => [ target, - failedResultFor(target, errorMessageFor(error)) + failedResultFor(target, errorMessage) ])) } } @@ -122,3 +156,10 @@ function predictionKeyFor( ): string { return `${baseBranch}\u0000${branchName}` } + +function debugTargetFor(payload: AiPredictionEvidencePayload): AiPredictionDebugTarget { + return { + branchName: payload.branch.name, + baseBranch: payload.branch.baseBranch + } +} diff --git a/src/ai/types.ts b/src/ai/types.ts index 8477d02..c40098d 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -32,8 +32,36 @@ export type AiPredictionClient = { predict(prompt: AiPredictionPrompt): Promise } -// AI prediction runner가 prompt 생성을 조정하기 위한 설정 -export type AiPredictionRunOptions = AiPredictionPromptBuildOptions +export type AiPredictionDebugTarget = { + branchName: string + baseBranch: string +} + +export type AiPredictionPromptDebugEvent = { + targetBranches: AiPredictionDebugTarget[] + prompt: AiPredictionPrompt +} + +export type AiPredictionResponseDebugEvent = { + targetBranches: AiPredictionDebugTarget[] + response: unknown +} + +export type AiPredictionFailureDebugEvent = { + targetBranches: AiPredictionDebugTarget[] + errorMessage: string +} + +export type AiPredictionDebugObserver = { + onPromptBuilt?(event: AiPredictionPromptDebugEvent): void | Promise + onResponseReceived?(event: AiPredictionResponseDebugEvent): void | Promise + onPredictionFailed?(event: AiPredictionFailureDebugEvent): void | Promise +} + +// AI prediction runner가 prompt 생성과 debug 기록을 조정하기 위한 설정 +export type AiPredictionRunOptions = AiPredictionPromptBuildOptions & { + debugObserver?: AiPredictionDebugObserver +} // branch별 AI prediction 실행 결과 export type AiPredictionResult = diff --git a/src/index.ts b/src/index.ts index 1cfe28e..9019d68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,12 +46,17 @@ export type { export type { AiPrediction, AiPredictionClient, + AiPredictionDebugObserver, + AiPredictionDebugTarget, AiPredictionEvidencePayload, AiPredictionFailedResult, + AiPredictionFailureDebugEvent, AiPredictionPrompt, AiPredictionPromptBuildOptions, + AiPredictionPromptDebugEvent, AiPredictionPromptResponseShape, AiPredictionPredictedResult, + AiPredictionResponseDebugEvent, AiPredictionResult, AiPredictionRunOptions, AiPredictionSkippedResult, diff --git a/tests/ai/predictionRunner.test.ts b/tests/ai/predictionRunner.test.ts index 4763d92..0bd9169 100644 --- a/tests/ai/predictionRunner.test.ts +++ b/tests/ai/predictionRunner.test.ts @@ -4,8 +4,11 @@ import { BranchRiskStatus, predictMergeRisksWithAi, type AiPredictionClient, + type AiPredictionFailureDebugEvent, type AiPredictionEvidencePayload, type AiPredictionPrompt, + type AiPredictionPromptDebugEvent, + type AiPredictionResponseDebugEvent, type BranchContext, type BranchRisk, type BranchRiskReasonCode, @@ -151,6 +154,59 @@ test("passes custom system prompt to client", async () => { assert.equal(client.prompts[0]?.systemPrompt, "Return compact JSON.") }) +// AI prompt와 provider response를 debug observer로 전달하는지 확인 +test("notifies debug observer with prompt and response", async () => { + const prompts: AiPredictionPromptDebugEvent[] = [] + const responses: AiPredictionResponseDebugEvent[] = [] + const client = new AiPredictionClientSpy() + + await predictMergeRisksWithAi([ + payload("feature/critical", 100, BranchRiskStatus.Critical) + ], client, { + debugObserver: { + onPromptBuilt: event => { + prompts.push(event) + }, + onResponseReceived: event => { + responses.push(event) + } + } + }) + + assert.deepEqual(prompts[0]?.targetBranches, [{ + branchName: "feature/critical", + baseBranch: "main" + }]) + assert.equal(prompts[0]?.prompt.responseShape, "predictionBatch") + assert.deepEqual(responses[0]?.targetBranches, [{ + branchName: "feature/critical", + baseBranch: "main" + }]) + assert.deepEqual(responses[0]?.response, validBatchResponse(["feature/critical"])) +}) + +// AI provider나 response 검증 실패를 debug observer로 전달하는지 확인 +test("notifies debug observer when prediction fails", async () => { + const failures: AiPredictionFailureDebugEvent[] = [] + const client = new AiPredictionClientSpy(new Error("provider failed")) + + await predictMergeRisksWithAi([ + payload("feature/critical", 100, BranchRiskStatus.Critical) + ], client, { + debugObserver: { + onPredictionFailed: event => { + failures.push(event) + } + } + }) + + assert.deepEqual(failures[0]?.targetBranches, [{ + branchName: "feature/critical", + baseBranch: "main" + }]) + assert.match(failures[0]?.errorMessage ?? "", /provider failed/) +}) + class AiPredictionClientSpy implements AiPredictionClient { prompts: AiPredictionPrompt[] = [] From e593224f14b6fc8bc97b41fd7654db15e710b443 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:03:43 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20merge=20risk=20debug=20artifact=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/workflows/mergeRiskWatch.ts | 94 ++++++++++++++++++++++++-- tests/workflows/mergeRiskWatch.test.ts | 6 +- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 1236207..bb785ad 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -2,15 +2,20 @@ import { execFile } from "node:child_process" import { fileURLToPath } from "node:url" import { resolve } from "node:path" import { promisify } from "node:util" -import { collectBranchContexts } from "../branches/branchCollector.js" +import { selectWithReasons as selectBranchesWithReasons } from "../branches/branchSelector.js" import { collectGitMergeSignal } from "../git/gitMergeSignalCollector.js" import { analyze as analyzeBranchMergeRisks } from "../risks/riskAnalyzer.js" import { build as buildAiPredictionEvidencePayload } from "../ai/evidenceBuilder.js" import { createDefaultAiPredictionClient } from "../ai/openAiPredictionClient.js" import { predict as predictMergeRisksWithAi } from "../ai/predictionRunner.js" +import { select as selectAiPredictionTargets } from "../ai/predictionTargetSelector.js" import { build as buildMergeRiskReport } from "../reports/reportBuilder.js" import { format as formatMergeRiskReportMarkdown } from "../reports/markdownFormatter.js" import { send as sendMergeRiskReport } from "../reportChannels/reportChannel.js" +import { writerFor as debugArtifactWriterFor } from "../debug/debugArtifact.js" +import type { + AiPredictionEvidencePayload +} from "../ai/types.js" import type { BranchCheckMetadata, BranchPullRequestMetadata, @@ -32,6 +37,8 @@ type MergeRiskWatchOptions = { remoteName: string githubApiUrl: string githubToken?: string + debugArtifactDir?: string + workflowRef?: string fetch?: typeof fetch } @@ -73,6 +80,9 @@ export function optionsFromEnvironment( const baseBranch = requiredEnv(env, "WATCHER_BASE_BRANCH") const defaultBranch = optionalEnv(env, "WATCHER_DEFAULT_BRANCH") + const debugArtifactDir = optionalEnv(env, "WATCHER_DEBUG_ARTIFACT_DIR") + const workflowRef = optionalEnv(env, "WATCHER_WORKFLOW_REF") + return { repository, repositoryPath, @@ -81,7 +91,9 @@ export function optionsFromEnvironment( criticalFilePatterns: patternsFrom(optionalEnv(env, "WATCHER_CRITICAL_FILE_PATTERNS")), remoteName: optionalEnv(env, "WATCHER_REMOTE_NAME") ?? "origin", githubApiUrl: optionalEnv(env, "WATCHER_GITHUB_API_URL") ?? "https://api.github.com", - githubToken: optionalEnv(env, "GITHUB_TOKEN") + githubToken: optionalEnv(env, "GITHUB_TOKEN"), + ...(debugArtifactDir ? { debugArtifactDir } : {}), + ...(workflowRef ? { workflowRef } : {}) } } @@ -92,12 +104,35 @@ export async function runFromEnvironment(): Promise { // local git checkout에서 branch signal을 수집하고 report channel로 전송 export async function run(options: MergeRiskWatchOptions): Promise { - const branches = await collectBranchContexts(branchSourceFor(options), { + const generatedAt = new Date() + const debugArtifactWriter = debugArtifactWriterFor(options.debugArtifactDir) + + await debugArtifactWriter?.writeJson("run.json", { + repository: options.repository, + repositoryPath: options.repositoryPath, + baseBranch: options.baseBranch, + defaultBranch: options.defaultBranch, + criticalFilePatterns: options.criticalFilePatterns, + remoteName: options.remoteName, + githubApiUrl: options.githubApiUrl, + workflowRef: options.workflowRef, + generatedAt + }) + + const repositoryBranches = await branchSourceFor(options).listBranches() + const branchSelection = selectBranchesWithReasons(repositoryBranches, { baseBranch: options.baseBranch, defaultBranch: options.defaultBranch }) + const branches = branchSelection.selected const inputs: BranchRiskAnalysisInput[] = [] + await debugArtifactWriter?.writeJson("branch-selection.json", { + repositoryBranches, + selectedBranches: branchSelection.selected, + excludedBranches: branchSelection.excluded + }) + for (const branch of branches) { const gitSignal = await collectGitMergeSignal(branch, { repositoryPath: options.repositoryPath, @@ -125,19 +160,50 @@ export async function run(options: MergeRiskWatchOptions): Promise { const evidencePayloads = inputs.map((input, index) => buildAiPredictionEvidencePayload(input, risks[index]!) ) + const aiTargets = selectAiPredictionTargets(evidencePayloads) + const aiTargetSet = new Set(aiTargets) + + await debugArtifactWriter?.writeJson("deterministic-evidence.json", { + inputs, + risks, + evidencePayloads + }) + await debugArtifactWriter?.writeJson("ai-target-selection.json", { + targetBranches: aiTargets.map(aiDebugTargetFor), + skippedBranches: evidencePayloads + .filter(payload => !aiTargetSet.has(payload)) + .map(payload => ({ + ...aiDebugTargetFor(payload), + reason: aiSkippedReasonFor(payload) + })) + }) + const predictions = await predictMergeRisksWithAi( evidencePayloads, - createDefaultAiPredictionClient() + createDefaultAiPredictionClient(), + { + debugObserver: debugArtifactWriter + ? { + onPromptBuilt: event => debugArtifactWriter.writeJson("ai-prompt.json", event), + onResponseReceived: event => debugArtifactWriter.writeJson("ai-response.json", event), + onPredictionFailed: event => debugArtifactWriter.writeJson("ai-error.json", event) + } + : undefined + } ) const report = buildMergeRiskReport(risks.map((risk, index) => ({ risk, branch: inputs[index]!.branch, aiPrediction: predictions[index] })), options.baseBranch, { - generatedAt: new Date() + generatedAt }) + const markdown = formatMergeRiskReportMarkdown(report) + + await debugArtifactWriter?.writeText("report.md", markdown) + const result = await sendMergeRiskReport({ - markdown: formatMergeRiskReportMarkdown(report) + markdown }) if (!result.ok) { @@ -145,6 +211,22 @@ export async function run(options: MergeRiskWatchOptions): Promise { } } +function aiDebugTargetFor(payload: AiPredictionEvidencePayload): { + branchName: string + baseBranch: string +} { + return { + branchName: payload.branch.name, + baseBranch: payload.branch.baseBranch + } +} + +function aiSkippedReasonFor(payload: AiPredictionEvidencePayload): "not_target" | "confirmed_conflict" { + return payload.possibility.reasons.some(reason => reason.code === "confirmed_conflict") + ? "confirmed_conflict" + : "not_target" +} + // remote tracking branch 목록을 BranchSource로 제공 function branchSourceFor(options: MergeRiskWatchOptions): { listBranches(): Promise diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 37d673d..733df0a 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -14,6 +14,8 @@ test("builds merge risk watch options from environment", () => { WATCHER_DEFAULT_BRANCH: "main", WATCHER_CRITICAL_FILE_PATTERNS: "package-lock.json\n.github/**", WATCHER_GITHUB_API_URL: "https://api.github.test", + WATCHER_DEBUG_ARTIFACT_DIR: "/tmp/watcher-debug", + WATCHER_WORKFLOW_REF: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@develop", GITHUB_TOKEN: "github-token" }) @@ -28,7 +30,9 @@ test("builds merge risk watch options from environment", () => { ], remoteName: "origin", githubApiUrl: "https://api.github.test", - githubToken: "github-token" + githubToken: "github-token", + debugArtifactDir: "/tmp/watcher-debug", + workflowRef: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@develop" }) }) From 42bae6fefdcd641f73b607dd3c9c128f38f49629 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:06:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20debug=20artifact=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20workflow=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/merge-risk-watch.yml | 20 ++++++++++++++ README.md | 29 +++++++++++++++++++++ docs/examples/consumer-merge-risk-watch.yml | 6 +++++ 3 files changed, 55 insertions(+) diff --git a/.github/workflows/merge-risk-watch.yml b/.github/workflows/merge-risk-watch.yml index 47cbdd4..48000b3 100644 --- a/.github/workflows/merge-risk-watch.yml +++ b/.github/workflows/merge-risk-watch.yml @@ -26,6 +26,11 @@ on: required: false type: string default: "" + upload_debug_artifact: + description: "AI prediction 원인 추적용 debug artifact를 consumer workflow run에 업로드할지 여부" + required: false + type: boolean + default: false secrets: watcher_github_token: description: "감시 대상 repository checkout, fetch, metadata 조회에 사용할 token" @@ -61,6 +66,11 @@ on: required: false type: string default: "" + upload_debug_artifact: + description: "AI prediction 원인 추적용 debug artifact를 업로드할지 여부" + required: false + type: boolean + default: false permissions: contents: read @@ -152,7 +162,17 @@ jobs: WATCHER_REPOSITORY: ${{ inputs.repository }} WATCHER_GITHUB_API_URL: ${{ github.api_url }} WATCHER_REPOSITORY_PATH: ${{ github.workspace }}/watched-repository + WATCHER_WORKFLOW_REF: ${{ job.workflow_ref }} + WATCHER_DEBUG_ARTIFACT_DIR: ${{ inputs.upload_debug_artifact && format('{0}/watcher/debug', github.workspace) || '' }} WATCHER_BASE_BRANCH: ${{ inputs.base_branch }} WATCHER_DEFAULT_BRANCH: ${{ inputs.default_branch }} WATCHER_CRITICAL_FILE_PATTERNS: ${{ inputs.critical_file_patterns }} run: npm run watch + + - name: Upload Watcher debug artifact + if: inputs.upload_debug_artifact + uses: actions/upload-artifact@v4 + with: + name: watcher-debug + path: ${{ github.workspace }}/watcher/debug + retention-days: 7 diff --git a/README.md b/README.md index 0c49d93..5f7b5a5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ reusable workflow는 다음 input을 받습니다. | `default_branch` | 선택 | 빈 값 | 감시 대상에서 제외할 default branch | | `critical_file_patterns` | 선택 | 빈 값 | score에 반영할 critical file wildcard pattern 목록. 줄바꿈으로 구분 | | `watcher_version` | 선택 | 빈 값 | 수동 테스트에 사용할 Watcher release tag. 비워두면 workflow ref 기준 | +| `upload_debug_artifact` | 선택 | `false` | AI prediction 원인 추적용 debug artifact를 consumer workflow run에 업로드할지 여부 | `critical_file_patterns`에서 `*`는 단일 path segment 내부를 매칭하고 `**`는 path separator를 포함해 매칭합니다. @@ -123,6 +124,32 @@ AI provider가 실패해도 deterministic possibility report는 유지됩니다. Report에는 `Low` section, AI prediction `skipped` 상태, branch `updated` 시각, provider error 요약을 유지합니다. Pull Request metadata는 내부 evidence로만 사용할 수 있으며 Markdown report에는 출력하지 않습니다. +## Debug artifact + +AI prediction 입력이나 provider 응답을 확인해야 할 때 consumer workflow에서 `upload_debug_artifact`를 `true`로 설정합니다. + +```yaml +with: + upload_debug_artifact: true +``` + +이 옵션을 켜면 consumer repository의 해당 GitHub Actions run에 `watcher-debug` artifact가 업로드됩니다. Watcher repository가 아니라 reusable workflow를 호출한 consumer repository의 Actions 화면에서 다운로드합니다. + +artifact에는 다음 파일이 포함됩니다. + +| 파일 | 내용 | +| --- | --- | +| `run.json` | repository, base branch, default branch, critical file patterns, Watcher workflow ref | +| `branch-selection.json` | 수집된 branch, 감시 대상 branch, 제외된 branch와 사유 | +| `deterministic-evidence.json` | git merge signal, changed files, changed hunks, check/PR metadata, deterministic risk 결과 | +| `ai-target-selection.json` | AI 호출 대상 branch와 skipped branch 사유 | +| `ai-prompt.json` | OpenAI에 전달한 system prompt, user prompt, response shape | +| `ai-response.json` | provider가 반환한 raw response | +| `ai-error.json` | provider 호출 또는 response validation 실패 요약. 실패가 없으면 생성되지 않을 수 있음 | +| `report.md` | 최종 Markdown report | + +debug artifact에는 `GITHUB_TOKEN`, `WATCHER_GITHUB_TOKEN`, `OPENAI_API_KEY`, `DISCORD_WEBHOOK_URL`을 기록하지 않습니다. raw file content와 raw diff 전문도 포함하지 않습니다. + ## Report channel Merge risk report는 Markdown으로 생성됩니다. consumer repository에 `DISCORD_WEBHOOK_URL` secret이 있으면 Discord webhook으로 전송하고, 없으면 stdout으로 출력합니다. @@ -150,6 +177,7 @@ WATCHER_BASE_BRANCH=develop \ WATCHER_DEFAULT_BRANCH=main \ WATCHER_CRITICAL_FILE_PATTERNS='package-lock.json .github/workflows/**' \ +WATCHER_DEBUG_ARTIFACT_DIR=/tmp/watcher-debug \ GITHUB_TOKEN=github-token \ OPENAI_API_KEY=openai-api-key \ DISCORD_WEBHOOK_URL=discord-webhook-url \ @@ -176,6 +204,7 @@ npm test | `default_branch` | 제외할 default branch. 예: `main` | | `watcher_version` | 테스트할 Watcher release tag. 비워두면 workflow ref 기준 | | `critical_file_patterns` | 테스트할 critical file pattern. 예: `package-lock.json`, `.github/workflows/**` | +| `upload_debug_artifact` | 문제 원인 추적이 필요할 때만 `true` | 3. consumer repository secret을 설정합니다. diff --git a/docs/examples/consumer-merge-risk-watch.yml b/docs/examples/consumer-merge-risk-watch.yml index 8358e14..73fc770 100644 --- a/docs/examples/consumer-merge-risk-watch.yml +++ b/docs/examples/consumer-merge-risk-watch.yml @@ -10,6 +10,11 @@ on: required: false type: string default: "" + upload_debug_artifact: + description: "AI prediction 원인 추적용 debug artifact를 업로드할지 여부" + required: false + type: boolean + default: false permissions: contents: read @@ -25,6 +30,7 @@ jobs: base_branch: develop default_branch: main watcher_version: ${{ inputs.watcher_version }} + upload_debug_artifact: ${{ inputs.upload_debug_artifact || false }} critical_file_patterns: | package-lock.json .github/workflows/** From 327f06ed467965079ad39d1c7f0916561024ca36 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:12:04 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20debug=20artifact=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/workflows/mergeRiskWatch.ts | 4 + tests/workflows/mergeRiskWatch.test.ts | 181 ++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f7b5a5..5bc8a9b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ artifact에는 다음 파일이 포함됩니다. | `ai-prompt.json` | OpenAI에 전달한 system prompt, user prompt, response shape | | `ai-response.json` | provider가 반환한 raw response | | `ai-error.json` | provider 호출 또는 response validation 실패 요약. 실패가 없으면 생성되지 않을 수 있음 | +| `ai-result.json` | response validation 이후 branch별 AI prediction, skipped, failed 매핑 결과 | | `report.md` | 최종 Markdown report | debug artifact에는 `GITHUB_TOKEN`, `WATCHER_GITHUB_TOKEN`, `OPENAI_API_KEY`, `DISCORD_WEBHOOK_URL`을 기록하지 않습니다. raw file content와 raw diff 전문도 포함하지 않습니다. diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index bb785ad..9e00888 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -191,6 +191,10 @@ export async function run(options: MergeRiskWatchOptions): Promise { : undefined } ) + await debugArtifactWriter?.writeJson("ai-result.json", { + predictions + }) + const report = buildMergeRiskReport(risks.map((risk, index) => ({ risk, branch: inputs[index]!.branch, diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 733df0a..29e96e6 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -1,10 +1,18 @@ import test from "node:test" import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" import { githubMetadataClientFor, - optionsFromEnvironment + optionsFromEnvironment, + run } from "../../src/workflows/mergeRiskWatch.js" +const execFileAsync = promisify(execFile) + // GitHub Actions 환경 변수에서 Watcher 실행 옵션을 구성하는지 확인 test("builds merge risk watch options from environment", () => { const options = optionsFromEnvironment({ @@ -59,6 +67,112 @@ test("omits empty optional environment values", () => { }) }) +// debug directory가 설정되면 workflow 실행 중 consumer artifact용 파일을 생성하는지 확인 +test("writes merge risk debug artifacts", async () => { + const fixture = await createWorkflowGitFixture() + const originalFetch = globalThis.fetch + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + let openAiRequestCount = 0 + + process.env.OPENAI_API_KEY = "openai-secret" + process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async (input, init) => { + const request = new Request(input, init) + + if (request.url === "https://discord.test/webhook-secret") { + return new Response(null, { + status: 204 + }) + } + + openAiRequestCount += 1 + assert.equal(request.url, "https://api.openai.com/v1/responses") + assert.equal(request.headers.get("Authorization"), "Bearer openai-secret") + + return jsonResponse({ + output_text: JSON.stringify({ + predictions: [{ + branchName: "feature/critical", + baseBranch: "main", + prediction: "critical file update needs review", + recommendedActions: [] + }, { + branchName: "feature/critical-peer", + baseBranch: "main", + prediction: "critical file overlap needs review", + recommendedActions: [] + }] + }) + }) + } + + try { + await run({ + repository: "opficdev/Watcher", + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + criticalFilePatterns: ["critical.txt"], + remoteName: "origin", + githubApiUrl: "https://api.github.test", + debugArtifactDir: fixture.debugArtifactDir, + workflowRef: "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#35-artifact" + }) + + const files = (await readdir(fixture.debugArtifactDir)).sort() + assert.deepEqual(files, [ + "ai-prompt.json", + "ai-response.json", + "ai-result.json", + "ai-target-selection.json", + "branch-selection.json", + "deterministic-evidence.json", + "report.md", + "run.json" + ]) + + const runArtifact = await readJson<{ + repository?: string + baseBranch?: string + workflowRef?: string + }>(fixture.debugArtifactDir, "run.json") + const aiResultArtifact = await readJson<{ + predictions?: Array<{ + status?: string + branchName?: string + }> + }>(fixture.debugArtifactDir, "ai-result.json") + const deterministicArtifact = await readJson<{ + risks?: Array<{ + status?: string + }> + }>(fixture.debugArtifactDir, "deterministic-evidence.json") + const combinedArtifact = (await Promise.all(files.map(file => + readFile(join(fixture.debugArtifactDir, file), "utf8") + ))).join("\n") + + assert.equal(openAiRequestCount, 1) + assert.equal(runArtifact.repository, "opficdev/Watcher") + assert.equal(runArtifact.baseBranch, "main") + assert.equal( + runArtifact.workflowRef, + "opficdev/Watcher/.github/workflows/merge-risk-watch.yml@feat/#35-artifact" + ) + assert.equal(deterministicArtifact.risks?.[0]?.status, "critical") + assert.equal(aiResultArtifact.predictions?.[0]?.status, "predicted") + assert.equal(aiResultArtifact.predictions?.[0]?.branchName, "feature/critical") + assert.doesNotMatch(combinedArtifact, /openai-secret/) + assert.doesNotMatch(combinedArtifact, /webhook-secret/) + assert.doesNotMatch(combinedArtifact, /feature critical content/) + assert.doesNotMatch(combinedArtifact, /peer critical content/) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + // 필수 환경 변수가 없으면 실행 전에 명확한 오류로 실패하는지 확인 test("throws when required environment is missing", () => { assert.throws( @@ -176,3 +290,68 @@ function jsonResponse(body: unknown): Response { } }) } + +async function createWorkflowGitFixture(): Promise<{ + repositoryPath: string + debugArtifactDir: string + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-workflow-fixture-")) + const repositoryPath = join(root, "repository") + const remotePath = join(root, "remote.git") + const debugArtifactDir = join(root, "debug") + + await git(root, ["init", "--initial-branch=main", repositoryPath]) + await git(repositoryPath, ["config", "user.email", "opfic@example.com"]) + await git(repositoryPath, ["config", "user.name", "opfic"]) + + await writeFile(join(repositoryPath, "critical.txt"), "base content\n") + await git(repositoryPath, ["add", "critical.txt"]) + await git(repositoryPath, ["commit", "-m", "initial"]) + + await git(repositoryPath, ["checkout", "-b", "feature/critical"]) + await writeFile(join(repositoryPath, "critical.txt"), "feature critical content\n") + await git(repositoryPath, ["commit", "-am", "feature critical"]) + + await git(repositoryPath, ["checkout", "main"]) + await git(repositoryPath, ["checkout", "-b", "feature/critical-peer"]) + await writeFile(join(repositoryPath, "critical.txt"), "peer critical content\n") + await git(repositoryPath, ["commit", "-am", "feature critical peer"]) + + await git(repositoryPath, ["checkout", "main"]) + await git(root, ["init", "--bare", remotePath]) + await git(repositoryPath, ["remote", "add", "origin", remotePath]) + await git(repositoryPath, ["push", "--quiet", "origin", "main", "feature/critical", "feature/critical-peer"]) + await git(repositoryPath, ["fetch", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*"]) + await mkdir(debugArtifactDir) + + return { + repositoryPath, + debugArtifactDir, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function readJson(directory: string, file: string): Promise { + return JSON.parse(await readFile(join(directory, file), "utf8")) as T +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name] + return + } + + process.env[name] = value +} + +async function git(cwd: string, args: string[]): Promise { + const result = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024 + }) + + return result.stdout.trim() +} From 1c844e475c72084b329085eae74aa8bdd79c3bc3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:26:02 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20debug=20artifact=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/debug/debugArtifact.ts | 16 ++++++++++++---- tests/debug/debugArtifact.test.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/debug/debugArtifact.ts b/src/debug/debugArtifact.ts index 92f8e2e..0f36877 100644 --- a/src/debug/debugArtifact.ts +++ b/src/debug/debugArtifact.ts @@ -20,13 +20,21 @@ class FileDebugArtifactWriter implements DebugArtifactWriter { } async writeText(name: string, value: string): Promise { - await mkdir(this.directory, { - recursive: true - }) - await writeFile(join(this.directory, name), value, "utf8") + try { + await mkdir(this.directory, { + recursive: true + }) + await writeFile(join(this.directory, name), value, "utf8") + } catch (error) { + console.warn(`Failed to write debug artifact ${name}: ${errorMessageFor(error)}`) + } } } +function errorMessageFor(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + function jsonValueFor(_key: string, value: unknown): unknown { if (value instanceof Date) { return value.toISOString() diff --git a/tests/debug/debugArtifact.test.ts b/tests/debug/debugArtifact.test.ts index 5f141a7..8ec6564 100644 --- a/tests/debug/debugArtifact.test.ts +++ b/tests/debug/debugArtifact.test.ts @@ -1,6 +1,6 @@ import test from "node:test" import assert from "node:assert/strict" -import { mkdtemp, readFile, rm } from "node:fs/promises" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import { writerFor } from "../../src/debug/debugArtifact.js" @@ -58,6 +58,35 @@ test("writes debug artifact text", async () => { } }) +// artifact 저장 실패가 main workflow 실패로 전파되지 않는지 확인 +test("warns and continues when debug artifact write fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "watcher-debug-")) + const filePath = join(directory, "not-directory") + const originalWarn = console.warn + const warnings: unknown[][] = [] + + console.warn = (...values: unknown[]) => { + warnings.push(values) + } + + try { + await writeFile(filePath, "not a directory") + + const writer = writerFor(filePath) + assert.ok(writer) + + await assert.doesNotReject(writer.writeText("report.md", "## Merge Risk Report\n")) + assert.equal(warnings.length, 1) + assert.match(String(warnings[0]?.[0]), /Failed to write debug artifact report\.md:/) + } finally { + console.warn = originalWarn + await rm(directory, { + recursive: true, + force: true + }) + } +}) + // directory가 없으면 artifact 기록을 비활성화하는지 확인 test("omits writer when debug artifact directory is missing", () => { assert.equal(writerFor(undefined), undefined) From 7fe72091dcdb065cefd2e0f197efa515d54b99f2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:27:21 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20debug=20observer=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/predictionRunner.ts | 29 +++++++++++---- tests/ai/predictionRunner.test.ts | 62 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/ai/predictionRunner.ts b/src/ai/predictionRunner.ts index 5052bc5..2007ce0 100644 --- a/src/ai/predictionRunner.ts +++ b/src/ai/predictionRunner.ts @@ -3,6 +3,7 @@ import { select as selectTargets } from "./predictionTargetSelector.js" import { validateBatch as validateBatchResponse } from "./predictionResponseValidator.js" import type { AiPrediction, + AiPredictionDebugObserver, AiPredictionDebugTarget, AiPredictionClient, AiPredictionEvidencePayload, @@ -39,10 +40,10 @@ async function predictedResultsFor( ): Promise> { const targetBranches = targets.map(debugTargetFor) const prompt = buildBatchPrompt(targets, options) - await options.debugObserver?.onPromptBuilt?.({ + await notifyDebugObserver("onPromptBuilt", () => options.debugObserver?.onPromptBuilt?.({ targetBranches, prompt - }) + })) let response: unknown @@ -50,10 +51,10 @@ async function predictedResultsFor( response = await client.predict(prompt) } catch (error) { const errorMessage = errorMessageFor(error) - await options.debugObserver?.onPredictionFailed?.({ + await notifyDebugObserver("onPredictionFailed", () => options.debugObserver?.onPredictionFailed?.({ targetBranches, errorMessage - }) + })) return new Map(targets.map(target => [ target, @@ -61,10 +62,10 @@ async function predictedResultsFor( ])) } - await options.debugObserver?.onResponseReceived?.({ + await notifyDebugObserver("onResponseReceived", () => options.debugObserver?.onResponseReceived?.({ targetBranches, response - }) + })) try { const predictions = validateBatchResponse(response) @@ -72,10 +73,10 @@ async function predictedResultsFor( return resultsByPayloadFor(targets, predictions) } catch (error) { const errorMessage = errorMessageFor(error) - await options.debugObserver?.onPredictionFailed?.({ + await notifyDebugObserver("onPredictionFailed", () => options.debugObserver?.onPredictionFailed?.({ targetBranches, errorMessage - }) + })) return new Map(targets.map(target => [ target, @@ -84,6 +85,18 @@ async function predictedResultsFor( } } +// debug observer 실패가 prediction 흐름을 중단하지 않도록 격리 +async function notifyDebugObserver( + eventName: keyof AiPredictionDebugObserver, + action: () => Promise | void | undefined +): Promise { + try { + await action() + } catch (error) { + console.warn(`Failed to notify debug observer (${eventName}): ${errorMessageFor(error)}`) + } +} + // unknown error를 report 가능한 문자열로 변환 function errorMessageFor(error: unknown): string { return error instanceof Error ? error.message : String(error) diff --git a/tests/ai/predictionRunner.test.ts b/tests/ai/predictionRunner.test.ts index 0bd9169..4260a8b 100644 --- a/tests/ai/predictionRunner.test.ts +++ b/tests/ai/predictionRunner.test.ts @@ -207,6 +207,56 @@ test("notifies debug observer when prediction fails", async () => { assert.match(failures[0]?.errorMessage ?? "", /provider failed/) }) +// prompt debug observer가 실패해도 AI prediction은 계속 진행되는지 확인 +test("continues prediction when prompt debug observer throws", async () => { + const client = new AiPredictionClientSpy() + const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ + payload("feature/critical", 100, BranchRiskStatus.Critical) + ], client, { + debugObserver: { + onPromptBuilt: () => { + throw new Error("prompt artifact failed") + } + } + })) + + assert.equal(client.prompts.length, 1) + assert.equal(results[0]?.status, "predicted") +}) + +// response debug observer가 실패해도 provider response 검증과 결과 매핑은 계속되는지 확인 +test("continues prediction when response debug observer throws", async () => { + const client = new AiPredictionClientSpy() + const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ + payload("feature/critical", 100, BranchRiskStatus.Critical) + ], client, { + debugObserver: { + onResponseReceived: () => { + throw new Error("response artifact failed") + } + } + })) + + assert.equal(results[0]?.status, "predicted") +}) + +// failure debug observer가 실패해도 branch별 failed result는 반환되는지 확인 +test("returns failed prediction when failure debug observer throws", async () => { + const client = new AiPredictionClientSpy(new Error("provider failed")) + const results = await suppressConsoleWarn(() => predictMergeRisksWithAi([ + payload("feature/critical", 100, BranchRiskStatus.Critical) + ], client, { + debugObserver: { + onPredictionFailed: () => { + throw new Error("failure artifact failed") + } + } + })) + + assert.equal(results[0]?.status, "failed") + assert.match(results[0]?.status === "failed" ? results[0].errorMessage : "", /provider failed/) +}) + class AiPredictionClientSpy implements AiPredictionClient { prompts: AiPredictionPrompt[] = [] @@ -235,6 +285,18 @@ function branchNamesFrom(prompt: AiPredictionPrompt): string[] { return evidence.branches.map(branch => branch.branch.name) } +async function suppressConsoleWarn(operation: () => Promise): Promise { + const originalWarn = console.warn + + console.warn = () => {} + + try { + return await operation() + } finally { + console.warn = originalWarn + } +} + function payload( branchName: string, score: number,