Translation Review (GitHub Models) #28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 사람이 연 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." |