From 5c6f78089b6616476fea5f99a35818fee9e340c0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:42:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20Gemini=20request=20body=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/geminiPredictionClient.ts | 24 ++++++++++++++++++++++-- tests/ai/geminiPredictionClient.test.ts | 17 ++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/ai/geminiPredictionClient.ts b/src/ai/geminiPredictionClient.ts index 4d8cd84..bf06029 100644 --- a/src/ai/geminiPredictionClient.ts +++ b/src/ai/geminiPredictionClient.ts @@ -68,7 +68,7 @@ export class GeminiPredictionClient implements AiPredictionClient { }) if (!response.ok) { - throw new Error(`Gemini prediction request failed with status ${response.status}`) + throw new Error(await geminiRequestErrorMessageFor(response)) } const value = await response.json() as GeminiGenerateContentResponse @@ -91,7 +91,7 @@ export function createDefaultAiPredictionClient( // Watcher가 검증할 AiPrediction shape를 Gemini structured output schema로 전달 function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record { return { - system_instruction: { + systemInstruction: { parts: [{ text: prompt.systemPrompt }] }, contents: [{ @@ -109,6 +109,26 @@ function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record { + 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() + + return trimmed || undefined + } catch { + return undefined + } +} + // Gemini 응답 part가 여러 개로 나뉘어도 하나의 JSON 문자열로 합침 function geminiTextFor(response: GeminiGenerateContentResponse): string { const text = response.candidates?.[0]?.content?.parts diff --git a/tests/ai/geminiPredictionClient.test.ts b/tests/ai/geminiPredictionClient.test.ts index d1d6482..1d5f10b 100644 --- a/tests/ai/geminiPredictionClient.test.ts +++ b/tests/ai/geminiPredictionClient.test.ts @@ -60,7 +60,7 @@ test("sends prompt to Gemini generateContent endpoint", async () => { assert.equal(request?.init.headers["x-goog-api-key"], "gemini-key") const body = JSON.parse(request?.init.body as string) as { - system_instruction: { + systemInstruction: { parts: Array<{ text: string }> @@ -79,7 +79,7 @@ test("sends prompt to Gemini generateContent endpoint", async () => { } } - assert.equal(body.system_instruction.parts[0]?.text, "Return JSON only.") + 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") }) @@ -102,13 +102,18 @@ test("throws when Gemini request fails", async () => { apiKey: "gemini-key", fetch: fetchSpy({}, { ok: false, - status: 429 + status: 429, + text: JSON.stringify({ + error: { + message: "model is overloaded" + } + }) }) }) await assert.rejects( client.predict(prompt()), - /Gemini prediction request failed with status 429/ + /Gemini prediction request failed with status 429: .*model is overloaded/ ) }) @@ -148,6 +153,7 @@ function fetchSpy( options: { ok?: boolean status?: number + text?: string } = {} ): FetchSpy { const requests: FetchSpy["requests"] = [] @@ -166,7 +172,8 @@ function fetchSpy( return { ok: options.ok ?? true, status: options.status ?? 200, - json: async () => body + json: async () => body, + text: async () => options.text ?? JSON.stringify(body) } as Response }) as FetchSpy From be5408fb5cdc6f6671270b27db950a6216ed1337 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:46:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Watcher=20version=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=85=EB=A0=A5=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/merge-risk-watch.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/merge-risk-watch.yml b/.github/workflows/merge-risk-watch.yml index f597f62..03a2d8e 100644 --- a/.github/workflows/merge-risk-watch.yml +++ b/.github/workflows/merge-risk-watch.yml @@ -21,6 +21,11 @@ on: required: false type: string default: "" + watcher_version: + description: "테스트할 Watcher release tag. 비워두면 workflow ref 기준" + required: false + type: string + default: "" secrets: watcher_github_token: description: "감시 대상 repository checkout, fetch, metadata 조회에 사용할 token" @@ -51,6 +56,11 @@ on: required: false type: string default: "" + watcher_version: + description: "테스트할 Watcher release tag. 비워두면 workflow ref 기준" + required: false + type: string + default: "" permissions: contents: read @@ -80,13 +90,16 @@ jobs: id: watcher_source env: WATCHER_WORKFLOW_REF: ${{ job.workflow_ref }} + WATCHER_VERSION: ${{ inputs.watcher_version }} run: | set -euo pipefail workflow_ref_name="${WATCHER_WORKFLOW_REF##*@}" release_tag="" - if [[ "$workflow_ref_name" == refs/tags/* ]]; then + if [[ -n "$WATCHER_VERSION" ]]; then + release_tag="$WATCHER_VERSION" + elif [[ "$workflow_ref_name" == refs/tags/* ]]; then release_tag="${workflow_ref_name#refs/tags/}" elif [[ "$workflow_ref_name" != refs/heads/* && "$workflow_ref_name" != refs/pull/* && ! "$workflow_ref_name" =~ ^[0-9a-f]{40}$ ]]; then release_tag="$workflow_ref_name" From 111822e9b6ef31f4b27023f2c5a1ece2c2250610 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:46:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20consumer=20workflow=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- docs/examples/consumer-merge-risk-watch.yml | 14 +++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dee1ed1..99f3c8b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ reusable workflow는 다음 input을 받습니다. | `base_branch` | 필수 | 없음 | merge risk를 비교할 기준 branch | | `default_branch` | 선택 | 빈 값 | 감시 대상에서 제외할 default branch | | `critical_file_patterns` | 선택 | 빈 값 | score에 반영할 critical file wildcard pattern 목록. 줄바꿈으로 구분 | +| `watcher_version` | 선택 | 빈 값 | 수동 테스트에 사용할 Watcher release tag. 비워두면 workflow ref 기준 | `critical_file_patterns`에서 `*`는 단일 path segment 내부를 매칭하고 `**`는 path separator를 포함해 매칭합니다. @@ -54,9 +55,9 @@ Watcher는 `base_branch`와 `default_branch`를 제외한 remote branch를 감 Watcher는 merge된 branch를 직접 삭제하지 않습니다. consumer repository의 `Settings` > `General` > `Pull Requests`에서 `Automatically delete head branches` 옵션을 켜야 합니다. 이 옵션을 켜면 merge된 branch가 자동으로 삭제되어 이미 merge된 branch를 계속 감시하는 상황을 줄일 수 있습니다. -예시 workflow는 `pull_request`, `schedule`, `workflow_dispatch`에서 실행됩니다. 일반 branch push만으로는 실행되지 않으며 PR 업데이트와 scheduled run에서 active branch 상태를 다시 확인합니다. +예시 workflow는 `schedule`, `workflow_dispatch`에서 실행됩니다. 일반 branch push나 PR 생성만으로는 실행되지 않으며 scheduled run에서 active branch 상태를 다시 확인합니다. -consumer repository의 예시 workflow는 `workflow_dispatch`를 지원하므로 Actions 화면에서 수동 테스트 실행이 가능합니다. +consumer repository의 예시 workflow는 `workflow_dispatch`를 지원하므로 Actions 화면에서 수동 테스트 실행이 가능합니다. `watcher_version`을 비워두면 `uses` ref 기준 release asset을 사용하고 특정 release tag를 입력하면 해당 version으로 테스트합니다. ## Release 배포 @@ -169,6 +170,7 @@ npm test | --- | --- | | `base_branch` | 기준 branch. 예: `develop` | | `default_branch` | 제외할 default branch. 예: `main` | +| `watcher_version` | 테스트할 Watcher release tag. 비워두면 workflow ref 기준 | | `critical_file_patterns` | 테스트할 critical file pattern. 예: `package-lock.json`, `.github/workflows/**` | 3. consumer repository secret을 설정합니다. diff --git a/docs/examples/consumer-merge-risk-watch.yml b/docs/examples/consumer-merge-risk-watch.yml index 9ae9ce6..f08cfe5 100644 --- a/docs/examples/consumer-merge-risk-watch.yml +++ b/docs/examples/consumer-merge-risk-watch.yml @@ -1,15 +1,15 @@ name: Merge Risk Watch on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review schedule: - cron: "0 15 * * 0-4" workflow_dispatch: + inputs: + watcher_version: + description: "테스트할 Watcher release tag. 비워두면 workflow ref 기준" + required: false + type: string + default: "" permissions: contents: read @@ -19,12 +19,12 @@ permissions: jobs: watch: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: opficdev/Watcher/.github/workflows/merge-risk-watch.yml@0.1.0 with: repository: ${{ github.repository }} base_branch: develop default_branch: main + watcher_version: ${{ inputs.watcher_version }} critical_file_patterns: | package-lock.json .github/workflows/** From 44badf8e1e028b2d9d65ed074aa153f9845241f6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:54:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20Gemini=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/geminiPredictionClient.ts | 7 +++++++ tests/ai/geminiPredictionClient.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/ai/geminiPredictionClient.ts b/src/ai/geminiPredictionClient.ts index bf06029..6708f53 100644 --- a/src/ai/geminiPredictionClient.ts +++ b/src/ai/geminiPredictionClient.ts @@ -3,6 +3,9 @@ import type { AiPredictionPrompt } from "./types.js" +// workflow log가 과도하게 커지지 않도록 Gemini 실패 응답 본문을 제한 +const MAX_GEMINI_ERROR_DETAIL_LENGTH = 1000 + // Watcher가 Gemini prediction에 기본으로 사용할 model 이름 export const DEFAULT_GEMINI_PREDICTION_MODEL = "gemini-3.5-flash" @@ -123,6 +126,10 @@ async function geminiErrorDetailFor(response: Response): Promise { ) }) +// 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}\.\.\. \(truncated\)/ + ) +}) + // Gemini 응답에 JSON text가 없으면 schema validation 이전에 명확한 오류가 발생하는지 확인 test("throws when Gemini response text is empty", async () => { const client = new GeminiPredictionClient({ From 7dc84c10f0b6d52dcc1b74a03c985fa8d76a4d17 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:05:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20CI=20=EC=8B=A4=ED=8C=A8=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 72 +++++++++++++++++++++++-- tests/ai/geminiPredictionClient.test.ts | 2 +- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beecdd8..ec23182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,11 +69,77 @@ jobs: shell: bash run: | log_file="$(mktemp)" + summary_file="$(mktemp)" + jobs_file="$(mktemp)" - if ! gh run view "${GITHUB_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --log-failed > "${log_file}"; then + extract_failure_summary() { + awk ' + /not ok |failureType:|error: \|-|AssertionError|The input did not match|Input:/ { + start = NR - 4 + end = NR + 20 + if (start < 1) { + start = 1 + } + for (line = start; line <= end; line += 1) { + selected[line] = 1 + } + } + /##\[error\]|Process completed with exit code/ { + start = NR - 4 + end = NR + if (start < 1) { + start = 1 + } + for (line = start; line <= end; line += 1) { + selected[line] = 1 + } + } + { + lines[NR] = $0 + } + END { + printed = 0 + for (line = 1; line <= NR; line += 1) { + if (selected[line]) { + print lines[line] + printed = 1 + } + } + if (!printed) { + start = NR - 79 + if (start < 1) { + start = 1 + } + for (line = start; line <= NR; line += 1) { + print lines[line] + } + } + } + ' "$1" + } + + gh run view "${GITHUB_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json jobs > "${jobs_file}" + + failed_job_id="$(jq -r ' + .jobs[] + | select(.conclusion == "failure") + | select(.name != "Report") + | .databaseId + ' "${jobs_file}" | head -n 1)" + + if [[ -n "${failed_job_id}" && "${failed_job_id}" != "null" ]]; then + gh run view "${GITHUB_RUN_ID}" \ + --repo "${GITHUB_REPOSITORY}" \ + --job "${failed_job_id}" \ + --log > "${log_file}" || echo "failed job log를 조회하지 못함" > "${log_file}" + elif ! gh run view "${GITHUB_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --log-failed > "${log_file}"; then echo "failed logs를 조회하지 못함" > "${log_file}" fi + extract_failure_summary "${log_file}" > "${summary_file}" + { echo "## CI 실패" echo @@ -82,8 +148,8 @@ jobs: echo "- 실행 로그: ${RUN_URL}" echo echo "\`\`\`text" - tail -n 120 "${log_file}" + cat "${summary_file}" echo "\`\`\`" } > comment.md - gh pr comment "${PR_NUMBER}" --body-file comment.md + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body-file comment.md diff --git a/tests/ai/geminiPredictionClient.test.ts b/tests/ai/geminiPredictionClient.test.ts index dc6ffe0..f4c343d 100644 --- a/tests/ai/geminiPredictionClient.test.ts +++ b/tests/ai/geminiPredictionClient.test.ts @@ -130,7 +130,7 @@ test("truncates long Gemini error response", async () => { await assert.rejects( client.predict(prompt()), - /Gemini prediction request failed with status 400: x{1000}\.\.\. \(truncated\)/ + /Gemini prediction request failed with status 400: x{1000}\.\.\. \(1000자 제한\)/ ) })