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
157 changes: 157 additions & 0 deletions .github/scripts/generate-pr-description.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bash

set -euo pipefail

TARGET_BRANCH="$1"
SOURCE_BRANCH="$2"

if [ -z "${GEMINI_API_KEY:-}" ]; then
echo "GEMINI_API_KEY is not configured." >&2
exit 1
fi

git fetch origin "$TARGET_BRANCH" --depth=1

MERGE_BASE=$(git merge-base "origin/$TARGET_BRANCH" HEAD)
COMMITS=$(git log --no-merges "$MERGE_BASE..HEAD" --oneline)
DIFF_STATS=$(git diff --stat "$MERGE_BASE..HEAD")
DIFF_CONTENT=$(git diff --unified=3 "$MERGE_BASE..HEAD" \
-- . \
':(exclude)package-lock.json' \
':(exclude)yarn.lock' \
':(exclude)pnpm-lock.yaml' \
':(exclude)dist/**' \
':(exclude).gitignore')

if [ -z "$DIFF_CONTENT" ]; then
{
echo "should_create=false"
echo "title=[chore] 변경 사항 없음"
echo "body<<EOF"
echo "변경 사항이 없어 PR을 생성하지 않습니다."
echo "EOF"
} >> "$GITHUB_OUTPUT"
exit 0
fi

PR_TEMPLATE=$(cat .github/PULL_REQUEST_TEMPLATE.md)

PROMPT=$(cat <<EOF
당신은 GitHub Pull Request 제목과 본문 초안을 작성하는 한국어 기술 문서 작성자입니다.

목표:
- feature 브랜치에서 test 브랜치로 보내는 PR의 제목과 본문 초안을 생성합니다.
- 저장소 PR 템플릿의 섹션 구조를 유지한 상태로 각 항목을 초안 형태로 채웁니다.

제목 규칙:
- 반드시 한국어로 작성합니다.
- 형식은 "[type] 작업 내용"입니다.
- type은 feat, fix, refactor, chore, docs, test, ci 중 하나만 사용합니다.
- 60자 이내를 권장합니다.

본문 규칙:
- 반드시 한국어 마크다운으로 작성합니다.
- 변경 사항은 커밋 목록, 변경 파일 통계, 상세 diff에 근거해 작성합니다.
- 아래에 제공되는 PR 템플릿의 헤더 구조와 순서를 그대로 유지합니다.
- 확실하지 않은 내용은 추측하지 말고 "확인 필요" 또는 "직접 보완 필요"라고 적습니다.
- 테스트를 실제로 실행했다고 추정하지 않습니다.
- 각 섹션은 1~3개의 bullet로 짧고 선명하게 작성합니다.
- HTML 주석은 제거하고 실제 초안 문장을 작성합니다.

출력 형식:
TITLE: [feat] 예시 제목
---
아래 PR 템플릿 구조를 유지한 완성된 마크다운 본문

PR 정보:
- base branch: $TARGET_BRANCH
- head branch: $SOURCE_BRANCH

PR 템플릿:
---
$PR_TEMPLATE
---

커밋 목록:
$COMMITS

변경 파일 통계:
$DIFF_STATS

상세 diff:
$DIFF_CONTENT
EOF
)

REQUEST_BODY=$(jq -n --arg prompt "$PROMPT" '{
contents: [
{
role: "user",
parts: [{ text: $prompt }]
}
],
generationConfig: {
temperature: 0.2
}
}')

GEMINI_RESPONSE=""
for attempt in 1 2 3; do
HTTP_RESPONSE=$(curl -sS -w '\n%{http_code}' -X POST \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
-H 'Content-Type: application/json' \
-d "$REQUEST_BODY")

HTTP_STATUS=$(printf '%s' "$HTTP_RESPONSE" | tail -n 1)
GEMINI_RESPONSE=$(printf '%s' "$HTTP_RESPONSE" | sed '$d')

if [ "$HTTP_STATUS" = "200" ]; then
break
fi

if [ "$attempt" -eq 3 ] || { [ "$HTTP_STATUS" != "429" ] && [ "${HTTP_STATUS#5}" = "$HTTP_STATUS" ]; }; then
echo "Gemini API request failed with status $HTTP_STATUS" >&2
echo "$GEMINI_RESPONSE" >&2
exit 1
fi

sleep $((attempt * 2))
done

FULL_RESPONSE=$(printf '%s' "$GEMINI_RESPONSE" | jq -r '
.candidates[0].content.parts
| map(.text // "")
| join("")
')

if [ -z "$FULL_RESPONSE" ] || [ "$FULL_RESPONSE" = "null" ]; then
echo "Gemini API returned an empty response." >&2
echo "$GEMINI_RESPONSE" >&2
exit 1
fi

PR_TITLE=$(printf '%s\n' "$FULL_RESPONSE" | grep '^TITLE:' | sed 's/^TITLE: //')
PR_BODY_DRAFT=$(printf '%s\n' "$FULL_RESPONSE" | sed '1,/^---$/d')

if [ -z "$PR_TITLE" ] || [ -z "$PR_BODY_DRAFT" ]; then
echo "Failed to parse Gemini response." >&2
exit 1
fi

PR_BODY=$(cat <<EOF
$PR_BODY_DRAFT
EOF
)

TITLE_FILE=$(mktemp)
BODY_FILE=$(mktemp)

printf '%s' "$PR_TITLE" > "$TITLE_FILE"
printf '%s' "$PR_BODY" > "$BODY_FILE"

{
echo "should_create=true"
echo "title=$PR_TITLE"
echo "title_file=$TITLE_FILE"
echo "body_file=$BODY_FILE"
} >> "$GITHUB_OUTPUT"
182 changes: 182 additions & 0 deletions .github/workflows/ai-pr-description.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: AI PR Summary

on:
pull_request:
branches:
- test
- main
types:
- opened
- reopened
- synchronize
- ready_for_review

permissions:
contents: read
pull-requests: write

concurrency:
group: ai-pr-summary-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
update-pr-summary:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Build PR diff
id: diff
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
git fetch origin "${BASE_REF}" --depth=1
git diff --unified=3 "origin/${BASE_REF}...HEAD" \
-- . \
':(exclude)package-lock.json' \
':(exclude)yarn.lock' \
':(exclude)pnpm-lock.yaml' \
':(exclude)dist/**' \
':(exclude).gitignore' \
> pr.diff

if [ ! -s pr.diff ]; then
echo "has_diff=false" >> "$GITHUB_OUTPUT"
else
echo "has_diff=true" >> "$GITHUB_OUTPUT"
fi

- name: Generate AI summary and update PR body
if: steps.diff.outputs.has_diff == 'true'
uses: actions/github-script@v7
env:
GEMINI_API_KEY: ${{ secrets.PR_GEMINI_API_KEY }}
PR_DIFF_PATH: pr.diff
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');

if (!process.env.GEMINI_API_KEY) {
core.setFailed('PR_GEMINI_API_KEY secret is not configured.');
return;
}

const diff = fs.readFileSync(process.env.PR_DIFF_PATH, 'utf8');
const existingBody = context.payload.pull_request.body ?? '';
const startMarker = '<!-- ai-pr-summary:start -->';
const endMarker = '<!-- ai-pr-summary:end -->';
const rawManualBody = existingBody.includes(startMarker)
? existingBody.split(startMarker)[0].trimEnd()
: existingBody.trimEnd();

const manualBody = rawManualBody
.replace(/(?:\n\s*---\s*)+$/g, '')
.trimEnd();

const prompt = `
당신은 GitHub Pull Request 하단에 붙는 AI 요약 블록을 작성하는 한국어 기술 문서 작성자입니다.

규칙:
- 반드시 한국어 마크다운으로 작성합니다.
- 변경 사항은 반드시 diff에 근거해 작성합니다.
- 확실하지 않은 내용은 추측하지 말고 "확인 필요"라고 적습니다.
- 각 섹션은 1~3개의 bullet로 짧고 선명하게 작성합니다.
- 아래 형식을 정확히 지킵니다.

출력 형식:
## 🤖 AI PR 분석 결과
_아래 내용은 변경 diff를 기준으로 자동 생성되었습니다._

### 📝 Summary
- ...

### ⚒️ 상세 변경 사항
- ...

### 🔍 리뷰어 주의사항
- ...

PR 정보:
- base branch: ${context.payload.pull_request.base.ref}
- head branch: ${context.payload.pull_request.head.ref}
- title: ${context.payload.pull_request.title}

상세 diff:
---
${diff}
---
`;

const endpoint =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=' +
encodeURIComponent(process.env.GEMINI_API_KEY);
const payload = {
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2 },
};

let response;
let lastErrorText = '';

for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

if (response.ok) {
break;
}

lastErrorText = await response.text();
const shouldRetry = response.status === 429 || response.status >= 500;

if (!shouldRetry || attempt === 3) {
core.setFailed(
`Gemini API request failed after ${attempt} attempt(s): ${response.status} ${lastErrorText}`,
);
return;
}

await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}

const data = await response.json();
const generatedText =
data.candidates?.[0]?.content?.parts
?.map((part) => part.text || '')
.join('')
.trim() || '';

if (!generatedText) {
core.setFailed('Gemini API returned an empty response.');
return;
}

const aiSection = `${startMarker}

${generatedText}

${endMarker}`;

const nextBody = manualBody
? `${manualBody}\n\n---\n\n${aiSection}`
: aiSection;

await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
body: nextBody,
});

- name: Skip when diff is empty
if: steps.diff.outputs.has_diff != 'true'
run: echo "No meaningful diff found after exclusions."
Loading
Loading