diff --git a/.github/scripts/generate-pr-description.sh b/.github/scripts/generate-pr-description.sh new file mode 100755 index 0000000..4abae56 --- /dev/null +++ b/.github/scripts/generate-pr-description.sh @@ -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<> "$GITHUB_OUTPUT" + exit 0 +fi + +PR_TEMPLATE=$(cat .github/PULL_REQUEST_TEMPLATE.md) + +PROMPT=$(cat <&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 < "$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" diff --git a/.github/workflows/ai-pr-description.yml b/.github/workflows/ai-pr-description.yml new file mode 100644 index 0000000..3dfe6b4 --- /dev/null +++ b/.github/workflows/ai-pr-description.yml @@ -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 = ''; + const endMarker = ''; + 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." diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml new file mode 100644 index 0000000..43edab8 --- /dev/null +++ b/.github/workflows/create-pr.yml @@ -0,0 +1,107 @@ +name: Automated PR Creation + +on: + push: + branches-ignore: + - main + - test + +env: + TARGET_BRANCH: test + +permissions: + contents: read + pull-requests: write + +concurrency: + group: create-pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + create-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for existing PR + id: check-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SOURCE_BRANCH="${GITHUB_REF#refs/heads/}" + existing_pr=$(gh pr list \ + --head "$SOURCE_BRANCH" \ + --base "${TARGET_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number') + + if [ -n "$existing_pr" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "PR already exists: #$existing_pr" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate PR title and description + if: steps.check-pr.outputs.exists == 'false' + id: generate-pr + env: + GEMINI_API_KEY: ${{ secrets.PR_GEMINI_API_KEY }} + run: | + SOURCE_BRANCH="${GITHUB_REF#refs/heads/}" + bash .github/scripts/generate-pr-description.sh \ + "${TARGET_BRANCH}" \ + "$SOURCE_BRANCH" + + - name: Create Pull Request + if: steps.check-pr.outputs.exists == 'false' && steps.generate-pr.outputs.should_create == 'true' + continue-on-error: true + id: create-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TZ: Asia/Seoul + run: | + SOURCE_BRANCH="${GITHUB_REF#refs/heads/}" + gh pr create \ + --base "${TARGET_BRANCH}" \ + --head "$SOURCE_BRANCH" \ + --title "$(cat "${{ steps.generate-pr.outputs.title_file }}")" \ + --body-file "${{ steps.generate-pr.outputs.body_file }}" + + - name: Publish PR draft summary + if: steps.check-pr.outputs.exists == 'false' && steps.generate-pr.outputs.should_create == 'true' + env: + SOURCE_BRANCH: ${{ github.ref_name }} + run: | + { + echo "## PR Draft Suggestion" + echo + if [ "${{ steps.create-pr.outcome }}" = "success" ]; then + echo "- Status: PR created successfully." + else + echo "- Status: Automatic PR creation failed. Use the draft below to open the PR manually." + echo "- Reason: GitHub Actions may not have permission to create pull requests in this repository." + fi + echo "- Base branch: ${TARGET_BRANCH}" + echo "- Head branch: ${SOURCE_BRANCH}" + echo + echo "### Suggested Title" + echo + cat "${{ steps.generate-pr.outputs.title_file }}" + echo + echo "### Suggested Draft Body" + echo + echo '```md' + cat "${{ steps.generate-pr.outputs.body_file }}" + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Skip when diff is empty + if: steps.check-pr.outputs.exists == 'false' && steps.generate-pr.outputs.should_create != 'true' + run: echo "No meaningful diff found against test."