Skip to content

Translation Review (GitHub Models) #28

Translation Review (GitHub Models)

Translation Review (GitHub Models) #28

# 사람이 연 PR → pull_request 로 리뷰.
# github-actions[bot] 이 GITHUB_TOKEN 으로 연 자동 번역 PR → 후속 pull_request 워크플로가 막히므로
# Auto Translate 완료 후 workflow_run 으로 같은 잡을 실행 (아티팩트로 PR 번호 전달).
name: Translation Review (GitHub Models)
on:
pull_request:
types: [opened, synchronize]
paths:
- "src/content/**/*.mdx"
workflow_run:
workflows: ["Auto Translate (GitHub Models)"]
types: [completed]
permissions:
contents: read
pull-requests: write
models: read
actions: read
jobs:
review:
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
steps:
- name: Download auto-translate PR metadata
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: auto-translate-pr-info
path: artifact
github-token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Resolve PR number
id: meta
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ ! -f artifact/auto-translate-pr-info.json ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::번역 PR 메타 아티팩트 없음 — 리뷰 스킵(번역 PR 미생성 또는 구버전 워크플로)."
exit 0
fi
PR_NUMBER=$(jq -r '.pr_number // empty' artifact/auto-translate-pr-info.json)
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Checkout repository
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/pull/${{ steps.meta.outputs.pr_number }}/merge
- name: Get changed MDX files
id: changed
if: steps.meta.outputs.skip != 'true'
run: |
FILES=$(gh pr diff ${{ steps.meta.outputs.pr_number }} --name-only | grep '\.mdx$' || true)
if [ -z "$FILES" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No MDX files changed."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "$FILES" > /tmp/changed-files.txt
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve PR head SHA
if: steps.meta.outputs.skip != 'true' && steps.changed.outputs.skip == 'false'
id: head
run: |
HEAD_SHA=$(gh pr view ${{ steps.meta.outputs.pr_number }} --json headRefOid -q '.headRefOid')
echo "sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Review with GitHub Models (file-chunked)
if: steps.meta.outputs.skip != 'true' && steps.changed.outputs.skip == 'false'
env:
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.head.outputs.sha }}
run: |
set -Eeuo pipefail
# GitHub Models 리뷰 API: 모델 불문 입력 ~8k 토큰 상한 → 파일 단위로 쪼개 여러 번 호출.
# 1파일 diff 도 크면 @@ hunk 로 2차 분할. auto-translate 의 split/retry 흐름과 동일 컨셉.
API_MODEL="openai/gpt-4o-mini"
# 시스템 프롬프트 + AGENTS 발췌가 들어가고도 여유 있도록, user 쪽 상한은 한 번에 6k bytes 로 보수적 고정
CHUNK_MAX_BYTES=6000
AGENTS=$(head -c 2000 AGENTS.md || true)
BASE_SHA=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName')
git fetch origin "$BASE_SHA" --depth=1 2>/dev/null || true
# 공용 함수 ------------------------------------------------------------
read -r -d '' SYSTEM_PROMPT << 'SYSPROMPT' || true
너는 React Hook Form 한국어 번역 프로젝트의 번역 품질 리뷰어다. 반드시 한국어로 답하라.
## 번역 규칙 요약 (AGENTS.md 일부)
PLACEHOLDER_AGENTS
## Word-Diff 표기
`[-삭제-]{+추가+}` 쌍을 찾아 분석. `.yml` 은 무시.
## Diff 라인 형식 (매우 중요)
각 본문 라인은 `L<숫자> | <원래 word-diff 라인>` 형태로 우측(병합 후) 라인 번호가 prepend 되어 있다.
- 예: `L42 | const result = [-old-]{+new+}` → 우측 라인 42
- `L---- | [-...-]` 처럼 번호가 없는 라인은 통째로 삭제된 라인이라 코멘트 대상이 아니다.
- 코멘트의 `line` 필드에는 반드시 그 prepend 된 숫자(예: 42)를 그대로 넣어라. **추정/계산 금지.**
- 라인 번호가 없는(삭제만 된) 변경에는 코멘트를 달지 마라.
## 평가 기준
1) 번역 규칙(용어표) 준수 2) 자연스러운 한국어 3) 의미 보존
## 출력 (반드시 JSON 객체 1개)
{"summary":"요약 1문장","comments":[{"path":"src/content/...mdx","line":정수,"body":"**변경:** `이전` → `이후`\n**평가:** 구체적 근거"}]}
## 규칙
- 규칙 위반·오역·어색한 번역투·정말 잘된 변경만 코멘트. 문제 없으면 comments=[].
- "좋아 보입니다" 류 금지, 반드시 근거 포함.
- line 은 반드시 해당 변경이 위치한 `L<숫자>` 의 숫자만 사용.
SYSPROMPT
SYSTEM_PROMPT="${SYSTEM_PROMPT//PLACEHOLDER_AGENTS/$AGENTS}"
is_token_error() {
local resp="$1"
echo "$resp" | jq -r '.error.code // empty' 2>/dev/null | grep -q 'tokens_limit_reached' && return 0
echo "$resp" | jq -r '.error.message // empty' 2>/dev/null | grep -qi 'too large\|max size' && return 0
return 1
}
call_api() {
local user_msg="$1"
local max_retries=5
local retry=0
local wait_sec=10
local tmpfile
while [ "$retry" -lt "$max_retries" ]; do
tmpfile=$(mktemp)
local http_code
http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" --max-time 120 \
"https://models.github.ai/inference/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GH_MODELS_TOKEN" \
-d "$(jq -n \
--arg model "$API_MODEL" \
--arg system "$SYSTEM_PROMPT" \
--arg user "$user_msg" \
'{model: $model, messages: [
{role: "system", content: $system},
{role: "user", content: $user}
], max_tokens: 2048, response_format: {type: "json_object"}}')" 2>/dev/null) || http_code="000"
local resp
resp=$(cat "$tmpfile"); rm -f "$tmpfile"
if [ "$http_code" = "429" ]; then
retry=$((retry + 1))
echo "⚠️ 429 rate-limit, ${wait_sec}s 대기 후 재시도 ($retry/$max_retries)" >&2
sleep "$wait_sec"; wait_sec=$((wait_sec * 2)); continue
fi
printf '%s' "$resp"
return
done
echo '{}'
}
# word-diff 를 $@ hunk(`@@ -... +... @@`) 단위로 분할
split_hunks() {
local input_file="$1" out_dir="$2"
mkdir -p "$out_dir"
awk -v out="$out_dir" '
BEGIN { n=0; file=out"/hdr.txt" }
/^@@ / { n++; file=sprintf("%s/hunk_%03d.txt", out, n) }
{ print >> file }
' "$input_file"
}
# 단일 diff 텍스트를 리뷰 → JSON 문자열 반환 (실패시 빈 JSON)
review_chunk() {
local path="$1" diff_text="$2"
local user_msg
user_msg="아래는 PR 에서 파일 \`$path\` 의 word-diff 이다 (각 라인 앞 \`L<숫자> |\` 가 우측 라인 번호).
JSON 한 개로만 응답하라. 파일명은 반드시 \"$path\" 로 표기. 코멘트의 line 필드에는 해당 변경 라인의 \`L<숫자>\` 숫자를 그대로 넣어라.
\`\`\`diff
$diff_text
\`\`\`"
# 안전한 크기 상한
if [ "$(printf '%s' "$user_msg" | wc -c)" -gt "$CHUNK_MAX_BYTES" ]; then
user_msg="$(printf '%s' "$user_msg" | head -c "$CHUNK_MAX_BYTES")"
fi
local resp
resp=$(call_api "$user_msg")
if is_token_error "$resp"; then
echo "::notice::[${path}] 토큰 초과 → hunk 분할 재시도" >&2
return 2
fi
local content
content=$(echo "$resp" | jq -r '.choices[0].message.content // empty' 2>/dev/null)
if [ -z "$content" ]; then
echo "::warning::[${path}] 응답 비어 있음: $(echo "$resp" | jq -c '.error // .' 2>/dev/null || echo "$resp" | head -c 300)" >&2
printf '%s' '{"summary":"","comments":[]}'
return 0
fi
# 유효 JSON만 받아서 반환
echo "$content" | jq -c '.' 2>/dev/null || printf '%s' '{"summary":"","comments":[]}'
}
# =====================================================================
# 파일별 word-diff 생성 + 리뷰 수집
# =====================================================================
ALL_COMMENTS='[]'
SUMMARIES=""
TOTAL_FILES=0
FAILED_FILES=0
while IFS= read -r FILE; do
[ -z "$FILE" ] && continue
[ "${FILE##*.}" = "yml" ] && continue
TOTAL_FILES=$((TOTAL_FILES + 1))
RAW_DIFF=$(mktemp)
git diff --word-diff "origin/$BASE_SHA"..HEAD -- "$FILE" > "$RAW_DIFF" 2>/dev/null || true
if [ ! -s "$RAW_DIFF" ]; then
rm -f "$RAW_DIFF"; continue
fi
# 우측 라인번호를 본문 앞에 prepend → 모델이 line 카운팅 안 해도 그대로 옮겨쓰면 됨
# - context / {+추가+} 라인: `L42| 본문`
# - 통째로 삭제된 라인([-...-] 만): `L---- | 본문` (우측 라인번호 없음)
# - hunk/index/파일 헤더: 그대로
DIFF_FILE=$(mktemp)
awk '
/^@@/ {
if (match($0, /\+([0-9]+)/, a)) n = a[1]+0
print; next
}
/^(diff |index |--- |\+\+\+ )/ { print; next }
{
# word-diff 본문: 한 줄에 [-...-] 만 있고 {+...+} 없으면 통째 삭제 (우측 라인 없음)
if (index($0, "[-") > 0 && index($0, "{+") == 0) {
printf "L---- | %s\n", $0
} else {
printf "L%-4d | %s\n", n, $0
n++
}
}
' "$RAW_DIFF" > "$DIFF_FILE"
rm -f "$RAW_DIFF"
if [ ! -s "$DIFF_FILE" ]; then
rm -f "$DIFF_FILE"; continue
fi
echo "=== 📄 Review: $FILE ($(wc -c < "$DIFF_FILE") bytes) ==="
CONTENT=$(cat "$DIFF_FILE")
CHUNK_JSON=$(review_chunk "$FILE" "$CONTENT" || true)
rc=$?
if [ "$rc" = "2" ]; then
# 토큰 초과 → hunk 단위 분할
HUNK_DIR=$(mktemp -d)
split_hunks "$DIFF_FILE" "$HUNK_DIR"
HUNK_JSONS='[]'
for hf in "$HUNK_DIR"/hunk_*.txt; do
[ -f "$hf" ] || continue
HUNK_CONTENT=$(cat "$hf")
SUB_JSON=$(review_chunk "$FILE" "$HUNK_CONTENT" || true)
if [ -n "$SUB_JSON" ]; then
HUNK_JSONS=$(jq -c --argjson a "$HUNK_JSONS" --argjson b "[$SUB_JSON]" -n '$a + $b')
fi
done
rm -rf "$HUNK_DIR"
# hunk 결과 병합
MERGED_COMMENTS=$(echo "$HUNK_JSONS" | jq '[.[] | .comments // []] | add // []')
MERGED_SUMMARY=$(echo "$HUNK_JSONS" | jq -r '[.[] | .summary // ""] | map(select(length>0)) | join(" / ")')
CHUNK_JSON=$(jq -c -n --argjson c "$MERGED_COMMENTS" --arg s "$MERGED_SUMMARY" '{summary:$s, comments:$c}')
fi
if [ -z "$CHUNK_JSON" ] || [ "$CHUNK_JSON" = "null" ]; then
FAILED_FILES=$((FAILED_FILES + 1))
rm -f "$DIFF_FILE"; continue
fi
# 모델이 path 를 다르게 쓸 수 있으니 FILE 로 강제 치환
CHUNK_JSON=$(echo "$CHUNK_JSON" | jq -c --arg p "$FILE" '.comments = (.comments // [] | map(.path=$p))')
NEW_COMMENTS=$(echo "$CHUNK_JSON" | jq -c '.comments // []')
ALL_COMMENTS=$(jq -c --argjson a "$ALL_COMMENTS" --argjson b "$NEW_COMMENTS" -n '$a + $b')
S=$(echo "$CHUNK_JSON" | jq -r '.summary // ""')
if [ -n "$S" ] && [ "$S" != "null" ]; then
SUMMARIES+="- \`$FILE\`: $S"$'\n'
fi
rm -f "$DIFF_FILE"
sleep 2
done < /tmp/changed-files.txt
COMMENTS_COUNT=$(echo "$ALL_COMMENTS" | jq 'length')
echo "Total files: $TOTAL_FILES, failed: $FAILED_FILES, comments: $COMMENTS_COUNT"
SUMMARY_BLOCK="${SUMMARIES:-전체적으로 번역 품질 이슈는 크지 않음.}"
if [ "$FAILED_FILES" -gt 0 ]; then
SUMMARY_BLOCK="$SUMMARY_BLOCK
> 일부 파일(${FAILED_FILES}개)은 토큰 초과/에러로 리뷰를 스킵했습니다."
fi
# =====================================================================
# 인라인 코멘트 line 검증/스냅
# GitHub PR Review API 는 line 이 PR patch 의 유효한 우측(추가/컨텍스트) 라인이어야 함.
# 모델이 word-diff 만 보고 추정한 line 이 빗나가면 422 (Line could not be resolved)
# → 파일별 patch 에서 유효 라인 집합을 만들고, 코멘트 line 을 가장 가까운 유효 라인으로 스냅.
# =====================================================================
if [ "$COMMENTS_COUNT" -gt 0 ]; then
echo "Validating inline comment lines against PR patches..."
PR_FILES_JSON=$(mktemp)
gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" > "$PR_FILES_JSON" || echo '[]' > "$PR_FILES_JSON"
ALL_COMMENTS_FIXED=$(ALL_COMMENTS_JSON="$ALL_COMMENTS" python3 - "$PR_FILES_JSON" <<'PY'
import json, re, sys, os
files_path = sys.argv[1]
with open(files_path) as f:
raw = f.read().strip()
try:
pr_files = json.loads(raw)
if isinstance(pr_files, dict):
pr_files = [pr_files]
except json.JSONDecodeError:
pr_files = []
for obj in re.findall(r'\[.*?\](?=\s*(?:\[|$))', raw, re.S):
try: pr_files.extend(json.loads(obj))
except Exception: pass
def valid_right_lines(patch: str):
lines = set()
new_no = None
for ln in patch.splitlines():
if ln.startswith('@@'):
m = re.search(r'\+(\d+)(?:,(\d+))?', ln)
if m: new_no = int(m.group(1))
continue
if new_no is None: continue
if ln.startswith('+++') or ln.startswith('---'): continue
if ln.startswith('+'):
lines.add(new_no); new_no += 1
elif ln.startswith('-'):
pass
else:
lines.add(new_no); new_no += 1
return lines
valid_map = {(f.get('filename') or ''): valid_right_lines(f.get('patch') or '') for f in pr_files}
comments = json.loads(os.environ['ALL_COMMENTS_JSON'])
kept, dropped = [], []
for c in comments:
p = c.get('path',''); body = c.get('body','')
try: line = int(c.get('line') or 0)
except Exception: line = 0
valid = valid_map.get(p)
if not valid:
dropped.append({'path': p, 'line': line, 'body': body}); continue
if line in valid:
kept.append({'path': p, 'line': line, 'side': 'RIGHT', 'body': body}); continue
snap = min(valid, key=lambda x: (abs(x-line), x))
kept.append({'path': p, 'line': snap, 'side': 'RIGHT',
'body': f"(원본 line={line} → patch 내 line={snap} 로 스냅)\n\n{body}"})
sys.stdout.write(json.dumps({'comments': kept, 'dropped': dropped}, ensure_ascii=False))
PY
)
REVIEW_COMMENTS=$(echo "$ALL_COMMENTS_FIXED" | jq -c '.comments')
DROPPED_COMMENTS=$(echo "$ALL_COMMENTS_FIXED" | jq -c '.dropped')
KEPT_COUNT=$(echo "$REVIEW_COMMENTS" | jq 'length')
DROPPED_COUNT=$(echo "$DROPPED_COMMENTS" | jq 'length')
echo "Inline kept: $KEPT_COUNT, dropped (path not in PR): $DROPPED_COUNT"
REVIEW_BODY="## 번역 리뷰 (GitHub Models)
### 요약 (파일별)
$SUMMARY_BLOCK
---
<sub>${API_MODEL} via GitHub Models | 파일 ${TOTAL_FILES}개 | 인라인 코멘트 ${KEPT_COUNT}개 | 스킵 ${DROPPED_COUNT}개</sub>"
if [ "$KEPT_COUNT" -gt 0 ]; then
REVIEW_PAYLOAD=$(jq -n \
--arg body "$REVIEW_BODY" \
--arg commit_id "$HEAD_SHA" \
--arg event "COMMENT" \
--argjson comments "$REVIEW_COMMENTS" \
'{body: $body, commit_id: $commit_id, event: $event, comments: $comments}')
echo "Submitting review (with inline comments)..."
REVIEW_OUT=$(mktemp)
REVIEW_ERR=$(mktemp)
if gh api --method POST \
-H "Accept: application/vnd.github+json" \
"/repos/$REPO/pulls/$PR_NUMBER/reviews" \
--input - <<< "$REVIEW_PAYLOAD" >"$REVIEW_OUT" 2>"$REVIEW_ERR"; then
REVIEW_OK=1
else
REVIEW_OK=0
REVIEW_RC=$?
fi
if [ "$REVIEW_OK" = "1" ]; then
REVIEW_ID=$(jq -r '.id // empty' "$REVIEW_OUT" 2>/dev/null)
REVIEW_URL=$(jq -r '.html_url // empty' "$REVIEW_OUT" 2>/dev/null)
echo "✅ Inline review posted (id=${REVIEW_ID:-?}) ${REVIEW_URL}"
else
echo "::warning::Review API 실패 (rc=${REVIEW_RC:-?}): $(head -c 500 "$REVIEW_ERR")"
echo "→ 요약 + 인라인 후보 목록을 일반 코멘트로 폴백"
FALLBACK_LIST=$(echo "$REVIEW_COMMENTS" | jq -r '[.[] | "- `" + .path + ":" + (.line|tostring) + "` " + (.body|split("\n")[0])] | .[]')
FALLBACK_BODY="$REVIEW_BODY
### 인라인 코멘트 (라인 매칭 실패로 일반 코멘트로 첨부)
$FALLBACK_LIST"
gh pr comment "$PR_NUMBER" --body "$FALLBACK_BODY"
fi
rm -f "$REVIEW_OUT" "$REVIEW_ERR"
else
echo "No valid-line comments left, posting summary only..."
gh pr comment "$PR_NUMBER" --body "$REVIEW_BODY
### 수정 제안
(인라인 위치 매칭 실패 또는 유효 라인 없음)"
fi
rm -f "$PR_FILES_JSON"
else
echo "No inline comments, posting summary only..."
COMMENT_BODY="## 번역 리뷰 (GitHub Models)
### 요약 (파일별)
$SUMMARY_BLOCK
### 수정 제안
수정 제안 없음
---
<sub>${API_MODEL} via GitHub Models | 파일 ${TOTAL_FILES}개</sub>"
gh pr comment "$PR_NUMBER" --body "$COMMENT_BODY"
fi
echo "Review posted successfully."