diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 000000000..578b6c5e5 --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,248 @@ +name: PR Coverage Check + +on: + pull_request_target: + branches: [develop, main] + paths: + - 'src/main/java/**/*.java' + - 'src/test/java/**/*.java' + - 'build.gradle' + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Get changed Java files + id: changed-files + run: | + set -euo pipefail + + CHANGED_MAIN_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^src/main/java/.*\.java$' || true) + CHANGED_MAIN_CLASSES=$(printf '%s\n' "$CHANGED_MAIN_FILES" | sed '/^$/d' | sed 's|src/main/java/||' | sed 's|/|.|g' | sed 's|\.java$||') + + echo "Changed main Java files (package format):" + echo "$CHANGED_MAIN_CLASSES" + + { + echo 'CHANGED_FILES<> "$GITHUB_ENV" + + FILE_COUNT=$(printf '%s\n' "$CHANGED_MAIN_CLASSES" | sed '/^$/d' | wc -l | tr -d ' ') + echo "FILE_COUNT=$FILE_COUNT" >> "$GITHUB_ENV" + echo "Changed file count: $FILE_COUNT" + + - name: Run tests with JaCoCo + run: | + ./gradlew test jacocoTestReport --no-daemon --build-cache + + - name: Parse coverage for changed files + id: parse-coverage + run: | + python3 << 'PYEOF' + import os + import re + import xml.etree.ElementTree as ET + + def write_outputs(overall_coverage, total_lines, covered_lines, body_type, coverage_table): + with open(os.getenv('GITHUB_OUTPUT'), 'a') as f: + f.write(f"overall_coverage={overall_coverage}\n") + f.write(f"total_lines={total_lines}\n") + f.write(f"covered_lines={covered_lines}\n") + f.write(f"body_type={body_type}\n") + f.write("coverage_table< 0 else 0 + + table_lines = [ + '| Class | Coverage | Lines | Status |', + '|-------|----------|-------|--------|', + ] + for result in results: + status = '✅' if result['ratio'] >= 70 else ('⚠️' if result['ratio'] >= 50 else '❌') + class_name = result['class'].split('.')[-1] + pkg = '.'.join(result['class'].split('.')[:-1]) + table_lines.append( + f"| {class_name}
{pkg} | **{result['ratio']:.1f}%** | {result['covered']}/{result['total']} | {status} |" + ) + + write_outputs(f"{overall_ratio:.1f}", total_lines, overall_covered, 'coverage', '\n'.join(table_lines)) + PYEOF + + - name: Fail if coverage below threshold + if: steps.parse-coverage.outputs.body_type == 'coverage' + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + run: | + COVERAGE_VAL=$(echo "$OVERALL_COVERAGE" | cut -d'.' -f1) + if [ "$COVERAGE_VAL" -lt 50 ]; then + echo "❌ 커버리지 ${OVERALL_COVERAGE}% 가 50% 임계값 미만입니다." + exit 1 + fi + echo "✅ 커버리지 ${OVERALL_COVERAGE}% 가 임계값을 충족합니다." + + - name: Comment PR with coverage results + uses: actions/github-script@v7 + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + TOTAL_LINES: ${{ steps.parse-coverage.outputs.total_lines }} + COVERED_LINES: ${{ steps.parse-coverage.outputs.covered_lines }} + BODY_TYPE: ${{ steps.parse-coverage.outputs.body_type }} + COVERAGE_TABLE: ${{ steps.parse-coverage.outputs.coverage_table }} + FILE_COUNT: ${{ env.FILE_COUNT }} + with: + script: | + const coverage = process.env.OVERALL_COVERAGE; + const totalLines = process.env.TOTAL_LINES; + const coveredLines = process.env.COVERED_LINES; + const bodyType = process.env.BODY_TYPE; + const table = process.env.COVERAGE_TABLE || ''; + const fileCount = process.env.FILE_COUNT || '0'; + + let body = '## 🧪 JaCoCo Coverage Report (Changed Files)\n\n'; + + if (bodyType === 'no-main-changes') { + body += '> 이 PR에서 변경된 main Java 소스 파일이 없습니다.\n'; + } else if (bodyType === 'no-coverage-data') { + body += '> 변경된 main Java 소스 파일에 대한 JaCoCo 데이터가 없습니다.\n'; + } else { + const ratio = parseFloat(coverage); + const status = ratio >= 70 ? '✅' : (ratio >= 50 ? '⚠️' : '❌'); + + body += `### Summary\n`; + body += `- **Overall Coverage:** ${coverage}% ${status}\n`; + body += `- **Covered Lines:** ${coveredLines} / ${totalLines}\n`; + body += `- **Changed Files:** ${fileCount}\n\n`; + body += `### Coverage by File\n`; + body += `${table}\n\n`; + + if (ratio < 50) { + body += '> ❌ **알림:** 커버리지가 50% 미만입니다. 테스트를 추가해주세요.\n'; + } else if (ratio < 70) { + body += '> ⚠️ **알림:** 커버리지가 70% 미만입니다. 테스트 추가를 권장합니다.\n'; + } + + body += `\n[📊 View Full Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n`; + } + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + + const existingComment = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('JaCoCo Coverage Report') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Upload JaCoCo report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: build/reports/jacoco/test/ + retention-days: 7 diff --git a/build.gradle b/build.gradle index 67742625b..0e1f9ae2d 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.8' id 'io.spring.dependency-management' version '1.1.7' id 'checkstyle' + id 'jacoco' } group = 'gg.agit' @@ -112,6 +113,43 @@ tasks.named('test') { } } +// JaCoCo 커버리지 리포트 설정 +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + } + + // 커버리지에서 제외할 클래스 설정 + afterEvaluate { + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("classes/java/main")) { + exclude([ + // DTO (dto 패키지 내부만 제외) + "**/dto/*.class", + // Entity + "**/domain/**/model/*.class", + "**/model/*.class", + // Configuration + "**/config/*.class", + "**/*Config.class", + // Exception + "**/exception/*.class", + "**/*Exception.class", + // 기타 + "**/Application.class", // Spring Boot 메인 클래스 + ]) + } + ) + } +} + +tasks.named('check') { + dependsOn jacocoTestReport +} + checkstyle { toolVersion = '10.12.5' configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..cdaf3ee2b --- /dev/null +++ b/lombok.config @@ -0,0 +1,3 @@ +# JaCoCo에서 롬복 생성 코드 제외 +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true