From 6ac271e197b4aaf33ba523e4b184b3d7fa3de5e0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:04:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20OpenAI=20prediction=20client=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/openAiPredictionClient.ts | 231 +++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/ai/openAiPredictionClient.ts diff --git a/src/ai/openAiPredictionClient.ts b/src/ai/openAiPredictionClient.ts new file mode 100644 index 0000000..2492606 --- /dev/null +++ b/src/ai/openAiPredictionClient.ts @@ -0,0 +1,231 @@ +import type { + AiPredictionClient, + AiPredictionPrompt +} from "./types.js" + +// workflow log가 과도하게 커지지 않도록 OpenAI 실패 응답 본문을 제한 +const MAX_OPENAI_ERROR_DETAIL_LENGTH = 1000 + +// Watcher가 OpenAI prediction에 기본으로 사용할 model 이름 +export const DEFAULT_OPENAI_PREDICTION_MODEL = "gpt-5.4-mini" + +// consumer repository에서 OpenAI API key를 주입할 environment variable 이름 +export const OPENAI_API_KEY_ENV_NAME = "OPENAI_API_KEY" + +// 호출 환경에 맞게 OpenAI 연결값을 바꿔 끼우기 위한 설정 +export type OpenAiPredictionClientOptions = { + apiKey?: string + model?: string + endpoint?: string + fetch?: typeof fetch +} + +// Watcher가 사용하는 OpenAI Responses API 응답 중 JSON text 추출에 필요한 최소 shape +type OpenAiResponsesApiResponse = { + output_text?: string + output?: Array<{ + content?: Array<{ + text?: string + }> + }> +} + +// OpenAI Responses API를 Watcher의 AiPredictionClient interface에 맞게 감쌈 +export class OpenAiPredictionClient implements AiPredictionClient { + // OpenAI API 인증에 사용할 key + private readonly apiKey: string + + // Responses API request body에 포함할 OpenAI model 이름 + private readonly model: string + + // API version 교체와 proxy 구성을 위해 분리한 OpenAI REST base URL + private readonly endpoint: string + + // 실행 환경별 HTTP 호출 구현을 바꿔 끼우기 위한 fetch 구현 + private readonly fetcher: typeof fetch + + // 명시 options 또는 OPENAI_API_KEY 환경 변수로 OpenAI client를 구성 + constructor(options: OpenAiPredictionClientOptions = {}) { + const apiKey = options.apiKey ?? process.env[OPENAI_API_KEY_ENV_NAME] + + if (!apiKey) { + throw new Error(`${OPENAI_API_KEY_ENV_NAME} is required to call OpenAI prediction`) + } + + this.apiKey = apiKey + this.model = options.model ?? DEFAULT_OPENAI_PREDICTION_MODEL + this.endpoint = options.endpoint ?? "https://api.openai.com/v1" + this.fetcher = options.fetch ?? fetch + } + + // system/user prompt를 OpenAI structured output 요청으로 변환하고 parsed JSON을 반환 + async predict(prompt: AiPredictionPrompt): Promise { + const response = await this.fetcher(this.url(), { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(openAiRequestBodyFor(prompt, this.model)) + }) + + if (!response.ok) { + throw new Error(await openAiRequestErrorMessageFor(response)) + } + + const value = await response.json() as OpenAiResponsesApiResponse + return JSON.parse(openAiTextFor(value)) + } + + // OpenAI Responses API 호출 URL을 구성 + private url(): string { + return `${this.endpoint}/responses` + } +} + +// Watcher 기본 AI provider를 OpenAI client로 조립 +export function createDefaultAiPredictionClient( + options: OpenAiPredictionClientOptions = {} +): AiPredictionClient { + return new OpenAiPredictionClient(options) +} + +// Watcher가 검증할 AiPrediction shape를 OpenAI structured output schema로 전달 +function openAiRequestBodyFor( + prompt: AiPredictionPrompt, + model: string +): Record { + const responseShape = prompt.responseShape ?? "prediction" + + return { + model, + input: [ + { + role: "developer", + content: prompt.systemPrompt + }, + { + role: "user", + content: prompt.userPrompt + } + ], + text: { + format: { + type: "json_schema", + name: responseShape === "predictionBatch" + ? "ai_prediction_batch" + : "ai_prediction", + strict: true, + schema: responseShape === "predictionBatch" + ? aiPredictionBatchSchema() + : aiPredictionSchema() + } + } + } +} + +// OpenAI API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인 +async function openAiRequestErrorMessageFor(response: Response): Promise { + const detail = await openAiErrorDetailFor(response) + const message = `OpenAI prediction request failed with status ${response.status}` + + return detail ? `${message}: ${detail}` : message +} + +// 실패 응답 body를 읽을 수 없거나 비어 있으면 기존 status 기반 오류만 사용 +async function openAiErrorDetailFor(response: Response): Promise { + try { + const text = await response.text() + const trimmed = text.trim() + + if (MAX_OPENAI_ERROR_DETAIL_LENGTH < trimmed.length) { + return trimmed.slice(0, MAX_OPENAI_ERROR_DETAIL_LENGTH) + "... (1000자 제한)" + } + + return trimmed || undefined + } catch { + return undefined + } +} + +// OpenAI 응답 text shortcut 또는 output content에서 JSON 문자열을 추출 +function openAiTextFor(response: OpenAiResponsesApiResponse): string { + if (response.output_text?.trim()) { + return response.output_text + } + + const text = response.output + ?.flatMap(item => item.content ?? []) + .map(content => content.text ?? "") + .join("") + + if (!text?.trim()) { + throw new Error("OpenAI prediction response text is empty") + } + + return text +} + +// OpenAI가 Watcher prediction batch contract에 맞는 JSON을 반환하도록 요청하는 schema +function aiPredictionBatchSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + predictions: { + type: "array", + items: aiPredictionSchema() + } + }, + required: ["predictions"] + } +} + +// OpenAI가 Watcher prediction 하나의 contract에 맞는 JSON을 반환하도록 요청하는 schema +function aiPredictionSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + branchName: { type: "string" }, + baseBranch: { type: "string" }, + prediction: { type: "string" }, + recommendedActions: { + type: "array", + items: recommendedActionSchema() + } + }, + required: [ + "branchName", + "baseBranch", + "prediction", + "recommendedActions" + ] + } +} + +// AI recommended action 하나의 JSON 구조를 OpenAI structured output schema로 표현 +function recommendedActionSchema(): Record { + return { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + description: { type: "string" }, + priority: { + type: "string", + enum: ["low", "medium", "high"] + }, + files: { + type: "array", + items: { type: "string" } + } + }, + required: [ + "title", + "description", + "priority", + "files" + ] + } +} From e730f0f28a2da39eacc5f29d4776ef2fe77734be Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:04:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EA=B8=B0=EB=B3=B8=20AI=20provi?= =?UTF-8?q?der=20OpenAI=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/merge-risk-watch.yml | 6 +- src/ai/predictionTargetSelector.ts | 2 +- src/index.ts | 12 +- src/workflows/mergeRiskWatch.ts | 2 +- tests/ai/geminiPredictionClient.test.ts | 440 ---------------------- tests/ai/openAiPredictionClient.test.ts | 271 +++++++++++++ tests/ai/predictionTargetSelector.test.ts | 2 +- tests/reports/markdownFormatter.test.ts | 4 +- tests/reports/reportBuilder.test.ts | 2 +- 9 files changed, 286 insertions(+), 455 deletions(-) delete mode 100644 tests/ai/geminiPredictionClient.test.ts create mode 100644 tests/ai/openAiPredictionClient.test.ts diff --git a/.github/workflows/merge-risk-watch.yml b/.github/workflows/merge-risk-watch.yml index 03a2d8e..47cbdd4 100644 --- a/.github/workflows/merge-risk-watch.yml +++ b/.github/workflows/merge-risk-watch.yml @@ -30,8 +30,8 @@ on: watcher_github_token: description: "감시 대상 repository checkout, fetch, metadata 조회에 사용할 token" required: true - gemini_api_key: - description: "Gemini prediction 호출에 사용할 API key" + openai_api_key: + description: "OpenAI prediction 호출에 사용할 API key" required: true discord_webhook_url: description: "선택 사항. report를 전송할 Discord webhook URL" @@ -147,7 +147,7 @@ jobs: working-directory: watcher env: GITHUB_TOKEN: ${{ secrets.watcher_github_token || secrets.WATCHER_GITHUB_TOKEN }} - GEMINI_API_KEY: ${{ secrets.gemini_api_key || secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.openai_api_key || secrets.OPENAI_API_KEY }} DISCORD_WEBHOOK_URL: ${{ secrets.discord_webhook_url || secrets.DISCORD_WEBHOOK_URL }} WATCHER_REPOSITORY: ${{ inputs.repository }} WATCHER_GITHUB_API_URL: ${{ github.api_url }} diff --git a/src/ai/predictionTargetSelector.ts b/src/ai/predictionTargetSelector.ts index 715d6ba..cd43c61 100644 --- a/src/ai/predictionTargetSelector.ts +++ b/src/ai/predictionTargetSelector.ts @@ -3,7 +3,7 @@ import type { } from "./types.js" import { BranchRiskStatus } from "../risks/types.js" -// Gemini 호출량을 줄이기 위해 기본 AI prediction 대상은 critical possibility로 제한 +// OpenAI 호출량을 줄이기 위해 기본 AI prediction 대상은 critical possibility로 제한 export const DEFAULT_AI_PREDICTION_TARGET_STATUS = BranchRiskStatus.Critical // deterministic possibility status 기준으로 AI prediction 대상 evidence만 선택 diff --git a/src/index.ts b/src/index.ts index d40a05c..1cfe28e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,10 @@ export { select as selectWatchedBranches } from "./branches/branchSelector.js" export { build as buildAiPredictionEvidencePayload } from "./ai/evidenceBuilder.js" export { createDefaultAiPredictionClient, - DEFAULT_GEMINI_PREDICTION_MODEL, - GEMINI_API_KEY_ENV_NAME, - GeminiPredictionClient -} from "./ai/geminiPredictionClient.js" + DEFAULT_OPENAI_PREDICTION_MODEL, + OPENAI_API_KEY_ENV_NAME, + OpenAiPredictionClient +} from "./ai/openAiPredictionClient.js" export { build as buildAiPredictionPrompt, buildBatch as buildAiPredictionBatchPrompt @@ -40,8 +40,8 @@ export { BranchRiskStatus } from "./risks/types.js" export type { BranchSource } from "./branches/branchCollector.js" export type { - GeminiPredictionClientOptions -} from "./ai/geminiPredictionClient.js" + OpenAiPredictionClientOptions +} from "./ai/openAiPredictionClient.js" export type { AiPrediction, diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 098e53b..1236207 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -6,7 +6,7 @@ import { collectBranchContexts } from "../branches/branchCollector.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/geminiPredictionClient.js" +import { createDefaultAiPredictionClient } from "../ai/openAiPredictionClient.js" import { predict as predictMergeRisksWithAi } from "../ai/predictionRunner.js" import { build as buildMergeRiskReport } from "../reports/reportBuilder.js" import { format as formatMergeRiskReportMarkdown } from "../reports/markdownFormatter.js" diff --git a/tests/ai/geminiPredictionClient.test.ts b/tests/ai/geminiPredictionClient.test.ts deleted file mode 100644 index 89efaa9..0000000 --- a/tests/ai/geminiPredictionClient.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import test from "node:test" -import assert from "node:assert/strict" -import { - createDefaultAiPredictionClient, - DEFAULT_GEMINI_PREDICTION_MODEL, - GEMINI_API_KEY_ENV_NAME, - GeminiPredictionClient, - type AiPredictionPrompt -} from "../../src/index.js" - -// GEMINI_API_KEY가 없으면 provider 호출 전에 명확한 설정 오류가 발생하는지 확인 -test("requires Gemini API key", () => { - const originalApiKey = process.env[GEMINI_API_KEY_ENV_NAME] - delete process.env[GEMINI_API_KEY_ENV_NAME] - - try { - assert.throws( - () => new GeminiPredictionClient({ - fetch: fetchSpy(validGeminiResponse()) - }), - new RegExp(GEMINI_API_KEY_ENV_NAME) - ) - } finally { - if (originalApiKey === undefined) { - delete process.env[GEMINI_API_KEY_ENV_NAME] - } else { - process.env[GEMINI_API_KEY_ENV_NAME] = originalApiKey - } - } -}) - -// Watcher 기본 AI provider factory가 Gemini client를 반환하는지 확인 -test("creates Gemini client as default AI prediction client", async () => { - const client = createDefaultAiPredictionClient({ - apiKey: "gemini-key", - fetch: fetchSpy(validGeminiResponse()) - }) - - const response = await client.predict(prompt()) - - assert.deepEqual(response, validPrediction()) -}) - -// Watcher prompt가 Gemini generateContent REST payload로 변환되는지 확인 -test("sends prompt to Gemini generateContent endpoint", async () => { - const fetcher = fetchSpy(validGeminiResponse()) - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetcher - }) - - await client.predict(prompt()) - - assert.equal(fetcher.requests.length, 1) - const request = fetcher.requests[0] - assert.equal( - request?.url, - `https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_PREDICTION_MODEL}:generateContent` - ) - assert.equal(request?.init.headers["x-goog-api-key"], "gemini-key") - - const body = JSON.parse(request?.init.body as string) as { - systemInstruction: { - parts: Array<{ - text: string - }> - } - contents: Array<{ - parts: Array<{ - text: string - }> - }> - generationConfig: { - responseFormat: { - text: { - mimeType: string - } - } - } - } - - assert.equal(body.systemInstruction.parts[0]?.text, "Return JSON only.") - assert.equal(body.contents[0]?.parts[0]?.text, "{\"branch\":\"feature/a\"}") - assert.equal(body.generationConfig.responseFormat.text.mimeType, "APPLICATION_JSON") -}) - -// batch prompt는 Gemini structured output schema도 predictions 배열로 요청하는지 확인 -test("sends batch response schema to Gemini", async () => { - const fetcher = fetchSpy(validGeminiBatchResponse()) - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetcher - }) - - await client.predict(batchPrompt()) - - const request = fetcher.requests[0] - const body = JSON.parse(request?.init.body as string) as { - generationConfig: { - responseFormat: { - text: { - schema: { - properties: { - predictions?: { - items?: { - properties?: Record - } - } - } - } - } - } - } - } - - assert.notEqual(body.generationConfig.responseFormat.text.schema.properties.predictions, undefined) - const properties = body.generationConfig.responseFormat.text.schema.properties.predictions?.items?.properties - assert.equal(properties?.confidence, undefined) - assert.equal(properties?.falsePositiveNotes, undefined) -}) - -// Gemini 503 계열 일시 실패는 exponential backoff 후 재시도하는지 확인 -test("retries Gemini high demand failures with exponential backoff", async () => { - const fetcher = fetchSequenceSpy([ - { - body: {}, - ok: false, - status: 503, - text: "high demand" - }, - { - body: {}, - ok: false, - status: 503, - text: "high demand" - }, - { - body: validGeminiResponse() - } - ]) - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetcher, - maxAttempts: 3, - retryBaseDelayMs: 0, - retryJitterMs: 0 - }) - - const response = await client.predict(prompt()) - - assert.deepEqual(response, validPrediction()) - assert.equal(fetcher.requests.length, 3) -}) - -// Gemini 429 rate limit은 일반 503보다 긴 대기 후 재시도하는지 확인 -test("retries Gemini rate limit failures with longer delay", async () => { - const fetcher = fetchSequenceSpy([ - { - body: {}, - ok: false, - status: 429, - text: "rate limit" - }, - { - body: validGeminiResponse() - } - ]) - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetcher, - maxAttempts: 2, - rateLimitDelayMs: 0, - retryJitterMs: 0 - }) - - const response = await client.predict(prompt()) - - assert.deepEqual(response, validPrediction()) - assert.equal(fetcher.requests.length, 2) -}) - -// request body 오류처럼 non-retryable 실패는 retry 없이 즉시 실패하는지 확인 -test("does not retry non-retryable Gemini request failure", async () => { - const fetcher = fetchSequenceSpy([ - { - body: {}, - ok: false, - status: 400, - text: "invalid request" - }, - { - body: validGeminiResponse() - } - ]) - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetcher, - maxAttempts: 5 - }) - - await assert.rejects( - client.predict(prompt()), - /Gemini prediction request failed with status 400: invalid request/ - ) - assert.equal(fetcher.requests.length, 1) -}) - -// Gemini quota 실패는 report가 길어지지 않도록 metric과 retry 시간만 요약하는지 확인 -test("summarizes Gemini quota failure", async () => { - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - maxAttempts: 1, - fetch: fetchSpy({}, { - ok: false, - status: 429, - text: JSON.stringify({ - error: { - code: 429, - message: [ - "You exceeded your current quota.", - "Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 20, model: gemini-3.5-flash", - "Please retry in 51.095224543s." - ].join("\n"), - status: "RESOURCE_EXHAUSTED" - } - }) - }) - }) - - await assert.rejects( - client.predict(prompt()), - /Gemini prediction request failed with status 429: Gemini quota exceeded, metric: generativelanguage.googleapis.com\/generate_content_free_tier_requests, limit: 20, model: gemini-3.5-flash, retry after: 51.095224543s/ - ) -}) - -// Gemini HTTP 실패가 branch 단위 failed result로 격리될 수 있도록 Error로 노출되는지 확인 -test("throws when Gemini request fails", async () => { - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - maxAttempts: 1, - fetch: fetchSpy({}, { - ok: false, - status: 429, - text: JSON.stringify({ - error: { - message: "model is overloaded" - } - }) - }) - }) - - await assert.rejects( - client.predict(prompt()), - /Gemini prediction request failed with status 429: .*model is overloaded/ - ) -}) - -// Gemini HTTP 실패 응답이 너무 길면 workflow log를 보호하기 위해 일부만 노출하는지 확인 -test("truncates long Gemini error response", async () => { - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetchSpy({}, { - ok: false, - status: 400, - text: "x".repeat(1200) - }) - }) - - await assert.rejects( - client.predict(prompt()), - /Gemini prediction request failed with status 400: x{1000}\.\.\. \(1000자 제한\)/ - ) -}) - -// Gemini 응답에 JSON text가 없으면 schema validation 이전에 명확한 오류가 발생하는지 확인 -test("throws when Gemini response text is empty", async () => { - const client = new GeminiPredictionClient({ - apiKey: "gemini-key", - fetch: fetchSpy({ - candidates: [{ - content: { - parts: [] - } - }] - }) - }) - - await assert.rejects( - client.predict(prompt()), - /response text is empty/ - ) -}) - -// Gemini client가 보낸 request를 테스트에서 검증하기 위한 fetch spy 타입 -type FetchSpy = typeof fetch & { - requests: Array<{ - url: string - init: { - headers: Record - body?: BodyInit | null - } - }> -} - -type FetchResponse = { - body: unknown - ok?: boolean - status?: number - text?: string -} - -// network 호출 없이 Gemini response와 HTTP 상태를 주입하는 fetch 대역 -function fetchSpy( - body: unknown, - options: { - ok?: boolean - status?: number - text?: string - } = {} -): FetchSpy { - const requests: FetchSpy["requests"] = [] - const spy = (async ( - input: string | URL | Request, - init?: RequestInit - ): Promise => { - requests.push({ - url: String(input), - init: { - headers: init?.headers as Record, - body: init?.body - } - }) - - return { - ok: options.ok ?? true, - status: options.status ?? 200, - json: async () => body, - text: async () => options.text ?? JSON.stringify(body) - } as Response - }) as FetchSpy - - spy.requests = requests - return spy -} - -// 여러 Gemini 응답을 순서대로 반환해 retry 흐름을 검증하는 fetch 대역 -function fetchSequenceSpy(responses: FetchResponse[]): FetchSpy { - const requests: FetchSpy["requests"] = [] - const spy = (async ( - input: string | URL | Request, - init?: RequestInit - ): Promise => { - requests.push({ - url: String(input), - init: { - headers: init?.headers as Record, - body: init?.body - } - }) - - const response = responses.shift() - - if (!response) { - throw new Error("Unexpected Gemini request") - } - - return responseFor(response) - }) as FetchSpy - - spy.requests = requests - return spy -} - -// 테스트용 response 정의를 fetch Response 최소 구현으로 변환 -function responseFor(response: FetchResponse): Response { - return { - ok: response.ok ?? true, - status: response.status ?? 200, - json: async () => response.body, - text: async () => response.text ?? JSON.stringify(response.body) - } as Response -} - -// Gemini client가 전송할 system/user prompt fixture -function prompt(): AiPredictionPrompt { - return { - systemPrompt: "Return JSON only.", - userPrompt: "{\"branch\":\"feature/a\"}" - } -} - -function batchPrompt(): AiPredictionPrompt { - return { - systemPrompt: "Return JSON only.", - userPrompt: "{\"branches\":[{\"branch\":\"feature/a\"}]}", - responseShape: "predictionBatch" - } -} - -// Gemini generateContent가 반환하는 JSON text 응답 fixture -function validGeminiResponse(): unknown { - return { - candidates: [{ - content: { - parts: [{ - text: JSON.stringify(validPrediction()) - }] - } - }] - } -} - -function validGeminiBatchResponse(): unknown { - return { - candidates: [{ - content: { - parts: [{ - text: JSON.stringify({ - predictions: [validPrediction()] - }) - }] - } - }] - } -} - -// Watcher AI prediction schema를 만족하는 parsed JSON fixture -function validPrediction(): unknown { - return { - branchName: "feature/a", - baseBranch: "main", - prediction: "shared file 변경이 겹쳐 rebase 확인이 필요함", - recommendedActions: [{ - title: "base branch rebase", - description: "shared file 변경을 먼저 rebase해 실제 conflict 여부를 확인함", - priority: "high", - files: ["src/shared.ts"] - }] - } -} diff --git a/tests/ai/openAiPredictionClient.test.ts b/tests/ai/openAiPredictionClient.test.ts new file mode 100644 index 0000000..7f23042 --- /dev/null +++ b/tests/ai/openAiPredictionClient.test.ts @@ -0,0 +1,271 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + createDefaultAiPredictionClient, + DEFAULT_OPENAI_PREDICTION_MODEL, + OPENAI_API_KEY_ENV_NAME, + OpenAiPredictionClient, + type AiPredictionPrompt +} from "../../src/index.js" + +// OPENAI_API_KEY가 없으면 provider 호출 전에 명확한 설정 오류가 발생하는지 확인 +test("requires OpenAI API key", () => { + const originalApiKey = process.env[OPENAI_API_KEY_ENV_NAME] + delete process.env[OPENAI_API_KEY_ENV_NAME] + + try { + assert.throws( + () => new OpenAiPredictionClient({ + fetch: fetchSpy(validOpenAiResponse()) + }), + new RegExp(OPENAI_API_KEY_ENV_NAME) + ) + } finally { + if (originalApiKey === undefined) { + delete process.env[OPENAI_API_KEY_ENV_NAME] + } else { + process.env[OPENAI_API_KEY_ENV_NAME] = originalApiKey + } + } +}) + +// Watcher 기본 AI provider factory가 OpenAI client를 반환하는지 확인 +test("creates OpenAI client as default AI prediction client", async () => { + const client = createDefaultAiPredictionClient({ + apiKey: "openai-key", + fetch: fetchSpy(validOpenAiResponse()) + }) + + const response = await client.predict(prompt()) + + assert.deepEqual(response, validPrediction()) +}) + +// Watcher prompt가 OpenAI Responses API payload로 변환되는지 확인 +test("sends prompt to OpenAI responses endpoint", async () => { + const fetcher = fetchSpy(validOpenAiResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: fetcher + }) + + await client.predict(prompt()) + + assert.equal(fetcher.requests.length, 1) + const request = fetcher.requests[0] + assert.equal(request?.url, "https://api.openai.com/v1/responses") + assert.equal(request?.init.headers["Authorization"], "Bearer openai-key") + + const body = JSON.parse(request?.init.body as string) as { + model: string + input: Array<{ + role: string + content: string + }> + text: { + format: { + type: string + name: string + strict: boolean + } + } + } + + assert.equal(body.model, DEFAULT_OPENAI_PREDICTION_MODEL) + assert.equal(body.input[0]?.role, "developer") + assert.equal(body.input[0]?.content, "Return JSON only.") + assert.equal(body.input[1]?.role, "user") + assert.equal(body.input[1]?.content, "{\"branch\":\"feature/a\"}") + assert.equal(body.text.format.type, "json_schema") + assert.equal(body.text.format.name, "ai_prediction") + assert.equal(body.text.format.strict, true) +}) + +// batch prompt는 OpenAI structured output schema도 predictions 배열로 요청하는지 확인 +test("sends batch response schema to OpenAI", async () => { + const fetcher = fetchSpy(validOpenAiBatchResponse()) + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: fetcher + }) + + await client.predict(batchPrompt()) + + const request = fetcher.requests[0] + const body = JSON.parse(request?.init.body as string) as { + text: { + format: { + name: string + schema: { + additionalProperties?: boolean + properties: { + predictions?: { + items?: { + additionalProperties?: boolean + properties?: Record + } + } + } + } + } + } + } + + assert.equal(body.text.format.name, "ai_prediction_batch") + assert.equal(body.text.format.schema.additionalProperties, false) + assert.notEqual(body.text.format.schema.properties.predictions, undefined) + const predictionSchema = body.text.format.schema.properties.predictions?.items + assert.equal(predictionSchema?.additionalProperties, false) + assert.equal(predictionSchema?.properties?.confidence, undefined) + assert.equal(predictionSchema?.properties?.falsePositiveNotes, undefined) +}) + +// OpenAI HTTP 실패가 branch 단위 failed result로 격리될 수 있도록 Error로 노출되는지 확인 +test("throws when OpenAI request fails", async () => { + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: fetchSpy({}, { + ok: false, + status: 429, + text: JSON.stringify({ + error: { + message: "rate limit exceeded" + } + }) + }) + }) + + await assert.rejects( + client.predict(prompt()), + /OpenAI prediction request failed with status 429: .*rate limit exceeded/ + ) +}) + +// OpenAI HTTP 실패 응답이 너무 길면 workflow log를 보호하기 위해 일부만 노출하는지 확인 +test("truncates long OpenAI error response", async () => { + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: fetchSpy({}, { + ok: false, + status: 400, + text: "x".repeat(1200) + }) + }) + + await assert.rejects( + client.predict(prompt()), + /OpenAI prediction request failed with status 400: x{1000}\.\.\. \(1000자 제한\)/ + ) +}) + +// OpenAI 응답에 JSON text가 없으면 schema validation 이전에 명확한 오류가 발생하는지 확인 +test("throws when OpenAI response text is empty", async () => { + const client = new OpenAiPredictionClient({ + apiKey: "openai-key", + fetch: fetchSpy({ + output: [{ + content: [] + }] + }) + }) + + await assert.rejects( + client.predict(prompt()), + /response text is empty/ + ) +}) + +// OpenAI client가 보낸 request를 테스트에서 검증하기 위한 fetch spy 타입 +type FetchSpy = typeof fetch & { + requests: Array<{ + url: string + init: { + headers: Record + body?: BodyInit | null + } + }> +} + +// network 호출 없이 OpenAI response와 HTTP 상태를 주입하는 fetch 대역 +function fetchSpy( + body: unknown, + options: { + ok?: boolean + status?: number + text?: string + } = {} +): FetchSpy { + const requests: FetchSpy["requests"] = [] + const spy = (async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + requests.push({ + url: String(input), + init: { + headers: init?.headers as Record, + body: init?.body + } + }) + + return { + ok: options.ok ?? true, + status: options.status ?? 200, + json: async () => body, + text: async () => options.text ?? JSON.stringify(body) + } as Response + }) as FetchSpy + + spy.requests = requests + return spy +} + +// OpenAI client가 전송할 system/user prompt fixture +function prompt(): AiPredictionPrompt { + return { + systemPrompt: "Return JSON only.", + userPrompt: "{\"branch\":\"feature/a\"}" + } +} + +function batchPrompt(): AiPredictionPrompt { + return { + systemPrompt: "Return JSON only.", + userPrompt: "{\"branches\":[{\"branch\":\"feature/a\"}]}", + responseShape: "predictionBatch" + } +} + +// OpenAI Responses API가 반환하는 JSON text 응답 fixture +function validOpenAiResponse(): unknown { + return { + output: [{ + content: [{ + text: JSON.stringify(validPrediction()) + }] + }] + } +} + +function validOpenAiBatchResponse(): unknown { + return { + output_text: JSON.stringify({ + predictions: [validPrediction()] + }) + } +} + +// Watcher AI prediction schema를 만족하는 parsed JSON fixture +function validPrediction(): unknown { + return { + branchName: "feature/a", + baseBranch: "main", + prediction: "shared file 변경이 겹쳐 rebase 확인이 필요함", + recommendedActions: [{ + title: "base branch rebase", + description: "shared file 변경을 먼저 rebase해 실제 conflict 여부를 확인함", + priority: "high", + files: ["src/shared.ts"] + }] + } +} diff --git a/tests/ai/predictionTargetSelector.test.ts b/tests/ai/predictionTargetSelector.test.ts index 50a2f1d..8cec880 100644 --- a/tests/ai/predictionTargetSelector.test.ts +++ b/tests/ai/predictionTargetSelector.test.ts @@ -22,7 +22,7 @@ test("selects critical targets by default", () => { assert.deepEqual(selected.map(target => target.branch.name), ["feature/critical"]) }) -// score가 높아도 critical status가 아니면 Gemini 호출 대상에서 제외되는지 확인 +// score가 높아도 critical status가 아니면 OpenAI 호출 대상에서 제외되는지 확인 test("skips high score non-critical targets", () => { const selected = selectAiPredictionTargets([ payload("feature/high", 90, BranchRiskStatus.High) diff --git a/tests/reports/markdownFormatter.test.ts b/tests/reports/markdownFormatter.test.ts index 14fc931..54bd094 100644 --- a/tests/reports/markdownFormatter.test.ts +++ b/tests/reports/markdownFormatter.test.ts @@ -137,12 +137,12 @@ test("formats failed AI result", () => { status: "failed", branchName: "feature/risk", baseBranch: "main", - errorMessage: "Gemini request failed" + errorMessage: "OpenAI request failed" })) assert.match(markdown, /- ai prediction:/) assert.match(markdown, /- status: `failed`/) - assert.match(markdown, /- error: Gemini request failed/) + assert.match(markdown, /- error: OpenAI request failed/) }) // section이 없으면 감시 대상 branch 없음 상태를 표시하는지 확인 diff --git a/tests/reports/reportBuilder.test.ts b/tests/reports/reportBuilder.test.ts index b07c5c0..424e542 100644 --- a/tests/reports/reportBuilder.test.ts +++ b/tests/reports/reportBuilder.test.ts @@ -158,7 +158,7 @@ test("keeps failed AI result when present", () => { status: "failed", branchName: "feature/failed", baseBranch: "main", - errorMessage: "Gemini request failed" + errorMessage: "OpenAI request failed" } const report = buildMergeRiskReport([ input("feature/failed", BranchRiskStatus.Medium, 25, {}, prediction) From 1ce2af8066f28ce66f550ff63d81e1dd5f531dcf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:04:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20Gemini=20prediction=20provider?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/geminiPredictionClient.ts | 357 ------------------------------- 1 file changed, 357 deletions(-) delete mode 100644 src/ai/geminiPredictionClient.ts diff --git a/src/ai/geminiPredictionClient.ts b/src/ai/geminiPredictionClient.ts deleted file mode 100644 index c5af5c1..0000000 --- a/src/ai/geminiPredictionClient.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { - AiPredictionClient, - AiPredictionPrompt -} from "./types.js" - -// workflow log가 과도하게 커지지 않도록 Gemini 실패 응답 본문을 제한 -const MAX_GEMINI_ERROR_DETAIL_LENGTH = 1000 - -// Gemini 서버 capacity나 일시적 내부 오류는 backoff 후 재시도 가능 -const RETRYABLE_GEMINI_STATUS_CODES = new Set([429, 500, 502, 503, 504]) - -// Gemini 일시 실패가 장기 장애인지 판단하기 전까지 허용할 총 요청 횟수 -const DEFAULT_GEMINI_MAX_ATTEMPTS = 5 - -// 503 계열 일시 실패의 exponential backoff 시작 대기 시간 -const DEFAULT_GEMINI_RETRY_BASE_DELAY_MS = 2000 - -// 429 rate limit은 서버 capacity와 별도로 더 길게 대기 -const DEFAULT_GEMINI_RATE_LIMIT_DELAY_MS = 60000 - -// 같은 시점에 retry가 몰리지 않도록 추가할 최대 jitter -const DEFAULT_GEMINI_RETRY_JITTER_MS = 1000 - -// Watcher가 Gemini prediction에 기본으로 사용할 model 이름 -export const DEFAULT_GEMINI_PREDICTION_MODEL = "gemini-3.5-flash" - -// consumer repository에서 Gemini API key를 주입할 environment variable 이름 -export const GEMINI_API_KEY_ENV_NAME = "GEMINI_API_KEY" - -// 호출 환경에 맞게 Gemini 연결값을 바꿔 끼우기 위한 설정 -export type GeminiPredictionClientOptions = { - apiKey?: string - model?: string - endpoint?: string - fetch?: typeof fetch - maxAttempts?: number - retryBaseDelayMs?: number - rateLimitDelayMs?: number - retryJitterMs?: number -} - -// Watcher가 사용하는 Gemini generateContent 응답 중 JSON text 추출에 필요한 최소 shape -type GeminiGenerateContentResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ - text?: string - }> - } - }> -} - -// Gemini generateContent REST API를 Watcher의 AiPredictionClient interface에 맞게 감쌈 -export class GeminiPredictionClient implements AiPredictionClient { - // Gemini API 인증에 사용할 key - private readonly apiKey: string - - // generateContent endpoint에 포함할 Gemini model 이름 - private readonly model: string - - // API version 교체와 proxy 구성을 위해 분리한 Gemini REST base URL - private readonly endpoint: string - - // 실행 환경별 HTTP 호출 구현을 바꿔 끼우기 위한 fetch 구현 - private readonly fetcher: typeof fetch - - // 일시 실패가 지속될 때 최대 몇 번까지 요청할지 결정 - private readonly maxAttempts: number - - // 503 계열 일시 실패의 exponential backoff 시작 대기 시간 - private readonly retryBaseDelayMs: number - - // 429 rate limit 응답 후 다음 요청까지 기다릴 시간 - private readonly rateLimitDelayMs: number - - // retry 요청이 동시에 몰리는 것을 피하기 위한 최대 jitter - private readonly retryJitterMs: number - - // 명시 options 또는 GEMINI_API_KEY 환경 변수로 Gemini client를 구성 - constructor(options: GeminiPredictionClientOptions = {}) { - const apiKey = options.apiKey ?? process.env[GEMINI_API_KEY_ENV_NAME] - - if (!apiKey) { - throw new Error(`${GEMINI_API_KEY_ENV_NAME} is required to call Gemini prediction`) - } - - this.apiKey = apiKey - this.model = options.model ?? DEFAULT_GEMINI_PREDICTION_MODEL - this.endpoint = options.endpoint ?? "https://generativelanguage.googleapis.com/v1beta" - this.fetcher = options.fetch ?? fetch - this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_GEMINI_MAX_ATTEMPTS) - this.retryBaseDelayMs = options.retryBaseDelayMs ?? DEFAULT_GEMINI_RETRY_BASE_DELAY_MS - this.rateLimitDelayMs = options.rateLimitDelayMs ?? DEFAULT_GEMINI_RATE_LIMIT_DELAY_MS - this.retryJitterMs = options.retryJitterMs ?? DEFAULT_GEMINI_RETRY_JITTER_MS - } - - // system/user prompt를 Gemini JSON 응답 요청으로 변환하고 parsed JSON을 반환 - async predict(prompt: AiPredictionPrompt): Promise { - const body = JSON.stringify(geminiRequestBodyFor(prompt)) - let lastResponse: Response | undefined - - for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) { - const response = await this.fetcher(this.url(), { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": this.apiKey - }, - body - }) - - if (response.ok) { - const value = await response.json() as GeminiGenerateContentResponse - return JSON.parse(geminiTextFor(value)) - } - - lastResponse = response - - if (!this.shouldRetry(response.status, attempt)) { - break - } - - await delay(this.retryDelayFor(response.status, attempt)) - } - - if (!lastResponse) { - throw new Error("Gemini prediction request failed without response") - } - - throw new Error(await geminiRequestErrorMessageFor(lastResponse)) - } - - // Gemini model별 generateContent 호출 URL을 구성 - private url(): string { - return `${this.endpoint}/models/${this.model}:generateContent` - } - - // retry 가능한 status이고 다음 시도 횟수가 남아 있는지 확인 - private shouldRetry(status: number, attempt: number): boolean { - return attempt + 1 < this.maxAttempts && RETRYABLE_GEMINI_STATUS_CODES.has(status) - } - - // 429는 더 길게 대기하고 503 계열은 exponential backoff로 간격을 늘림 - private retryDelayFor(status: number, attempt: number): number { - const jitter = Math.floor(Math.random() * this.retryJitterMs) - - if (status === 429) { - return this.rateLimitDelayMs + jitter - } - - return (2 ** attempt) * this.retryBaseDelayMs + jitter - } -} - -// Watcher 기본 AI provider를 Gemini client로 조립 -export function createDefaultAiPredictionClient( - options: GeminiPredictionClientOptions = {} -): AiPredictionClient { - return new GeminiPredictionClient(options) -} - -// Watcher가 검증할 AiPrediction shape를 Gemini structured output schema로 전달 -function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record { - const schema = prompt.responseShape === "predictionBatch" - ? aiPredictionBatchSchema() - : aiPredictionSchema() - - return { - systemInstruction: { - parts: [{ text: prompt.systemPrompt }] - }, - contents: [{ - role: "user", - parts: [{ text: prompt.userPrompt }] - }], - generationConfig: { - responseFormat: { - text: { - mimeType: "APPLICATION_JSON", - schema - } - } - } - } -} - -// 운영 환경에서는 retry 사이에 실제로 대기 -function delay(delayMs: number): Promise { - if (delayMs <= 0) { - return Promise.resolve() - } - - return new Promise(resolve => setTimeout(resolve, delayMs)) -} - -// Gemini API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인 -async function geminiRequestErrorMessageFor(response: Response): Promise { - const detail = await geminiErrorDetailFor(response) - const message = `Gemini prediction request failed with status ${response.status}` - - return detail ? `${message}: ${detail}` : message -} - -// 실패 응답 body를 읽을 수 없거나 비어 있으면 기존 status 기반 오류만 사용 -async function geminiErrorDetailFor(response: Response): Promise { - try { - const text = await response.text() - const trimmed = text.trim() - const rateLimitDetail = response.status === 429 - ? geminiRateLimitDetailFor(trimmed) - : undefined - - if (rateLimitDetail) { - return rateLimitDetail - } - - if (MAX_GEMINI_ERROR_DETAIL_LENGTH < trimmed.length) { - return trimmed.slice(0, MAX_GEMINI_ERROR_DETAIL_LENGTH) + "... (1000자 제한)" - } - - return trimmed || undefined - } catch { - return undefined - } -} - -// Gemini 429 quota 응답은 report에 긴 원문 대신 핵심 metric과 retry 시간만 표시 -function geminiRateLimitDetailFor(text: string): string | undefined { - const message = geminiErrorMessageFor(text) ?? text - const metric = valueAfter(message, "Quota exceeded for metric:") - const limit = valueAfter(message, "limit:") - const model = valueAfter(message, "model:") - const retry = retryDelayTextFor(message) - - if (!metric && !retry) { - return message || undefined - } - - return [ - "Gemini quota exceeded", - metric ? `metric: ${metric}` : undefined, - limit ? `limit: ${limit}` : undefined, - model ? `model: ${model}` : undefined, - retry ? `retry after: ${retry}` : undefined - ].filter((value): value is string => value !== undefined).join(", ") -} - -// JSON error body에서 Gemini가 제공한 message만 추출 -function geminiErrorMessageFor(text: string): string | undefined { - try { - const value = JSON.parse(text) as { - error?: { - message?: unknown - } - } - - return typeof value.error?.message === "string" ? value.error.message : undefined - } catch { - return undefined - } -} - -// "key: value" 형태의 Gemini quota message에서 한 항목만 추출 -function valueAfter(message: string, key: string): string | undefined { - const index = message.indexOf(key) - - if (index === -1) { - return undefined - } - - const value = message.slice(index + key.length).trim() - const end = value.search(/[,\n]/) - const trimmed = (end === -1 ? value : value.slice(0, end)).trim() - - return trimmed || undefined -} - -// Gemini quota message의 retry 안내 시간을 추출 -function retryDelayTextFor(message: string): string | undefined { - const match = message.match(/Please retry in ([0-9]+(?:\.[0-9]+)?s)/) - - return match?.[1] -} - -// Gemini 응답 part가 여러 개로 나뉘어도 하나의 JSON 문자열로 합침 -function geminiTextFor(response: GeminiGenerateContentResponse): string { - const text = response.candidates?.[0]?.content?.parts - ?.map(part => part.text ?? "") - .join("") - - if (!text) { - throw new Error("Gemini prediction response text is empty") - } - - return text -} - -// Gemini가 Watcher prediction contract에 맞는 JSON을 반환하도록 요청하는 schema -function aiPredictionBatchSchema(): Record { - return { - type: "object", - properties: { - predictions: { - type: "array", - items: aiPredictionSchema() - } - }, - required: [ - "predictions" - ] - } -} - -// Gemini가 Watcher prediction 하나의 contract에 맞는 JSON을 반환하도록 요청하는 schema -function aiPredictionSchema(): Record { - return { - type: "object", - properties: { - branchName: { type: "string" }, - baseBranch: { type: "string" }, - prediction: { type: "string" }, - recommendedActions: { - type: "array", - items: recommendedActionSchema() - } - }, - required: [ - "branchName", - "baseBranch", - "prediction" - ] - } -} - -// AI recommended action 하나의 JSON 구조를 Gemini structured output schema로 표현 -function recommendedActionSchema(): Record { - return { - type: "object", - properties: { - title: { type: "string" }, - description: { type: "string" }, - priority: { - type: "string", - enum: ["low", "medium", "high"] - }, - files: { - type: "array", - items: { type: "string" } - } - }, - required: [ - "title", - "description", - "priority" - ] - } -} From 3e0961744de596b4f0abb180d81c47fe92b9d738 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:04:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20OpenAI=20provider=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++++++++++----------- docs/examples/consumer-merge-risk-watch.yml | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index afb56fd..0c49d93 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ consumer repository에는 다음 secret을 설정합니다. | secret | 필수 여부 | 용도 | | --- | --- | --- | | `WATCHER_GITHUB_TOKEN` | 필수 | watched repository checkout, branch fetch, PR/check metadata 조회 | -| `GEMINI_API_KEY` | 필수 | Gemini prediction 생성 | +| `OPENAI_API_KEY` | 필수 | OpenAI prediction 생성 | | `DISCORD_WEBHOOK_URL` | 선택 | Discord webhook report 전송. 미설정 시 stdout으로 출력 | `WATCHER_GITHUB_TOKEN`은 watched repository를 checkout하고 branch, check, pull request metadata를 읽을 수 있어야 합니다. public repository라도 metadata 조회와 private repository 확장을 고려해 explicit token을 사용합니다. @@ -103,25 +103,25 @@ Watcher는 merge 가능/불가능을 단정하지 않고 branch별 signal을 충 ## AI-assisted prediction -AI prediction은 deterministic possibility score를 대체하지 않습니다. Watcher는 deterministic evidence를 Gemini API에 전달하고 AI는 실무 관점의 prediction과 recommended actions를 추가합니다. +AI prediction은 deterministic possibility score를 대체하지 않습니다. Watcher는 deterministic evidence를 OpenAI API에 전달하고 AI는 실무 관점의 prediction과 recommended actions를 추가합니다. -기본 AI provider는 Gemini API입니다. consumer repository에는 `GEMINI_API_KEY` secret을 설정해야 합니다. +기본 AI provider는 OpenAI Responses API입니다. consumer repository에는 `OPENAI_API_KEY` secret을 설정해야 합니다. AI prediction 대상은 기본적으로 `critical` possibility입니다. 이미 virtual merge에서 conflict가 확정된 branch는 AI 호출 없이 deterministic report만 사용합니다. 그 외 낮은 status의 branch는 AI 호출을 생략하고 `skipped` 상태로 report에 표시됩니다. -Gemini free tier의 RPM/RPD 사용량을 줄이기 위해 선택된 branch prediction은 report 단위 batch 요청으로 한 번에 실행합니다. +provider 호출량을 줄이기 위해 선택된 branch prediction은 report 단위 batch 요청으로 한 번에 실행합니다. AI prediction 결과는 다음 상태 중 하나입니다. | status | 의미 | | --- | --- | -| `predicted` | Gemini API 응답을 검증했고 prediction과 recommended actions를 report에 포함함 | +| `predicted` | OpenAI API 응답을 검증했고 prediction과 recommended actions를 report에 포함함 | | `skipped` | AI prediction 대상이 아니어서 provider 호출을 생략함 | | `failed` | provider 호출이나 응답 검증에 실패해 branch 단위 실패로 격리함 | AI provider가 실패해도 deterministic possibility report는 유지됩니다. 실패한 branch는 `failed` 상태와 error message를 report에 포함합니다. -Report에는 `Low` section, AI prediction `skipped` 상태, branch `updated` 시각, Gemini error 요약을 유지합니다. Pull Request metadata는 내부 evidence로만 사용할 수 있으며 Markdown report에는 출력하지 않습니다. +Report에는 `Low` section, AI prediction `skipped` 상태, branch `updated` 시각, provider error 요약을 유지합니다. Pull Request metadata는 내부 evidence로만 사용할 수 있으며 Markdown report에는 출력하지 않습니다. ## Report channel @@ -139,9 +139,9 @@ npm run build npm test ``` -`npm test`는 compiled JavaScript test를 실행합니다. 테스트는 provider와 report channel을 mock으로 검증하므로 실제 GitHub, Gemini, Discord 호출을 수행하지 않습니다. +`npm test`는 compiled JavaScript test를 실행합니다. 테스트는 provider와 report channel을 mock으로 검증하므로 실제 GitHub, OpenAI, Discord 호출을 수행하지 않습니다. -실제 runner를 local에서 실행하려면 watched repository checkout과 secret 환경 변수가 필요합니다. 이 실행은 GitHub API, Gemini API, Discord webhook을 호출할 수 있으므로 필요한 경우에만 사용합니다. +실제 runner를 local에서 실행하려면 watched repository checkout과 secret 환경 변수가 필요합니다. 이 실행은 GitHub API, OpenAI API, Discord webhook을 호출할 수 있으므로 필요한 경우에만 사용합니다. ```sh WATCHER_REPOSITORY=owner/repo \ @@ -151,7 +151,7 @@ WATCHER_DEFAULT_BRANCH=main \ WATCHER_CRITICAL_FILE_PATTERNS='package-lock.json .github/workflows/**' \ GITHUB_TOKEN=github-token \ -GEMINI_API_KEY=gemini-api-key \ +OPENAI_API_KEY=openai-api-key \ DISCORD_WEBHOOK_URL=discord-webhook-url \ npm run watch ``` @@ -182,12 +182,12 @@ npm test | secret | 테스트 기준 | | --- | --- | | `WATCHER_GITHUB_TOKEN` | 테스트 repository를 checkout하고 metadata를 읽을 수 있는 token | -| `GEMINI_API_KEY` | Gemini API 호출 가능한 key | +| `OPENAI_API_KEY` | OpenAI API 호출 가능한 key | | `DISCORD_WEBHOOK_URL` | 처음에는 설정하지 않음 | 4. consumer repository의 Actions 화면에서 `Merge Risk Watch` workflow를 수동 실행하고 stdout report를 확인합니다. -`DISCORD_WEBHOOK_URL`을 비워 두면 Discord로 전송하지 않고 GitHub Actions log에 Markdown report를 출력합니다. 이 단계에서 branch 수집, merge signal 수집, Gemini prediction, report 생성이 정상인지 확인합니다. +`DISCORD_WEBHOOK_URL`을 비워 두면 Discord로 전송하지 않고 GitHub Actions log에 Markdown report를 출력합니다. 이 단계에서 branch 수집, merge signal 수집, OpenAI prediction, report 생성이 정상인지 확인합니다. 5. stdout report가 정상일 때만 consumer repository secret에 `DISCORD_WEBHOOK_URL`을 추가하고 같은 workflow를 다시 수동 실행합니다. @@ -197,7 +197,7 @@ Discord 메시지가 정상적으로 도착하면 consumer repository의 예시 local test는 Watcher 내부 로직이 기대한 입력을 처리하는지 확인합니다. GitHub Actions의 reusable workflow, repository checkout, remote branch fetch, 실제 API 권한, schedule timing은 검증하지 않습니다. -scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `base_branch`와 `default_branch`를 제외한 branch를 대상으로 merge signal과 metadata를 다시 수집합니다. 따라서 local test가 통과해도 consumer repository의 token permission, branch 정리 상태, Gemini API key, Discord webhook 상태가 잘못되면 scheduled run에서 실패할 수 있습니다. +scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `base_branch`와 `default_branch`를 제외한 branch를 대상으로 merge signal과 metadata를 다시 수집합니다. 따라서 local test가 통과해도 consumer repository의 token permission, branch 정리 상태, OpenAI API key, Discord webhook 상태가 잘못되면 scheduled run에서 실패할 수 있습니다. ## Troubleshooting @@ -208,6 +208,6 @@ scheduled run은 consumer repository의 실제 remote branch를 fetch하고, `ba | PR metadata가 비어 있음 | `pull-requests: read` permission과 commit에 연결된 PR 존재 여부 확인 | | check metadata가 비어 있음 | `checks: read` permission과 해당 branch head SHA의 check run 존재 여부 확인 | | AI prediction이 `skipped`로 표시됨 | deterministic possibility status가 `critical`인지와 `confirmed_conflict`가 아닌지 확인 | -| AI prediction이 `failed`로 표시됨 | `GEMINI_API_KEY` secret, Gemini API 응답 형식, rate limit 상태 확인 | +| AI prediction이 `failed`로 표시됨 | `OPENAI_API_KEY` secret, OpenAI API 응답 형식, rate limit 상태 확인 | | Discord 전송이 되지 않음 | `DISCORD_WEBHOOK_URL` secret과 webhook channel 권한 확인 | | merge된 branch가 계속 감시됨 | GitHub `Automatically delete head branches` 설정과 원격 branch 삭제 상태 확인 | diff --git a/docs/examples/consumer-merge-risk-watch.yml b/docs/examples/consumer-merge-risk-watch.yml index f08cfe5..8358e14 100644 --- a/docs/examples/consumer-merge-risk-watch.yml +++ b/docs/examples/consumer-merge-risk-watch.yml @@ -30,5 +30,5 @@ jobs: .github/workflows/** secrets: watcher_github_token: ${{ secrets.WATCHER_GITHUB_TOKEN }} - gemini_api_key: ${{ secrets.GEMINI_API_KEY }} + openai_api_key: ${{ secrets.OPENAI_API_KEY }} discord_webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} From e6d12494831974ef5aec91ab2d4547cac332c3e3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:13:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20OpenAI=20error=20message=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/openAiPredictionClient.ts | 24 +++++++++++++++++++++--- tests/ai/openAiPredictionClient.test.ts | 7 +++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/ai/openAiPredictionClient.ts b/src/ai/openAiPredictionClient.ts index 2492606..471705b 100644 --- a/src/ai/openAiPredictionClient.ts +++ b/src/ai/openAiPredictionClient.ts @@ -137,12 +137,30 @@ async function openAiErrorDetailFor(response: Response): Promise { }) }) - await assert.rejects( - client.predict(prompt()), - /OpenAI prediction request failed with status 429: .*rate limit exceeded/ - ) + await assert.rejects(client.predict(prompt()), { + message: "OpenAI prediction request failed with status 429: rate limit exceeded" + }) }) // OpenAI HTTP 실패 응답이 너무 길면 workflow log를 보호하기 위해 일부만 노출하는지 확인