Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
15 changes: 14 additions & 1 deletion .github/workflows/merge-risk-watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 포함해 매칭합니다.

Expand All @@ -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 배포

Expand Down Expand Up @@ -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을 설정합니다.
Expand Down
14 changes: 7 additions & 7 deletions docs/examples/consumer-merge-risk-watch.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/**
Expand Down
31 changes: 29 additions & 2 deletions src/ai/geminiPredictionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -68,7 +71,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
Expand All @@ -91,7 +94,7 @@ export function createDefaultAiPredictionClient(
// Watcher가 검증할 AiPrediction shape를 Gemini structured output schema로 전달
function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record<string, unknown> {
return {
system_instruction: {
systemInstruction: {
parts: [{ text: prompt.systemPrompt }]
},
contents: [{
Expand All @@ -109,6 +112,30 @@ function geminiRequestBodyFor(prompt: AiPredictionPrompt): Record<string, unknow
}
}

// Gemini API가 반환한 HTTP 실패 원문을 보존해 workflow log에서 원인을 확인
async function geminiRequestErrorMessageFor(response: Response): Promise<string> {
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<string | undefined> {
try {
const text = await response.text()
const trimmed = text.trim()

if (MAX_GEMINI_ERROR_DETAIL_LENGTH < trimmed.length) {
return trimmed.slice(0, MAX_GEMINI_ERROR_DETAIL_LENGTH) + "... (1000자 제한)"
}

return trimmed || undefined
} catch {
return undefined
}
}
Comment thread
opficdev marked this conversation as resolved.

// Gemini 응답 part가 여러 개로 나뉘어도 하나의 JSON 문자열로 합침
function geminiTextFor(response: GeminiGenerateContentResponse): string {
const text = response.candidates?.[0]?.content?.parts
Expand Down
34 changes: 29 additions & 5 deletions tests/ai/geminiPredictionClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}>
Expand All @@ -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")
})
Expand All @@ -102,13 +102,35 @@ 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: .*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 429/
/Gemini prediction request failed with status 400: x{1000}\.\.\. \(1000자 제한\)/
)
})

Expand Down Expand Up @@ -148,6 +170,7 @@ function fetchSpy(
options: {
ok?: boolean
status?: number
text?: string
} = {}
): FetchSpy {
const requests: FetchSpy["requests"] = []
Expand All @@ -166,7 +189,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

Expand Down