From 49bdd5d0638ce205b906859f2b5bd60ab6e03611 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 17:21:05 +0800 Subject: [PATCH 1/8] ci: add PR coverage check workflow --- .github/workflows/coverage-check.yml | 364 +++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 .github/workflows/coverage-check.yml diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml new file mode 100644 index 0000000000..86e62ca0fd --- /dev/null +++ b/.github/workflows/coverage-check.yml @@ -0,0 +1,364 @@ +name: Coverage Check + +on: + pull_request: + branches: [ 'develop', 'release_**' ] + types: [ opened, synchronize, reopened ] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + # Fail the check if total coverage drops more than this percentage + COVERAGE_DROP_THRESHOLD: 1.0 + +jobs: + # Run tests on PR branch and base branch in parallel + coverage-pr: + name: Coverage (PR Branch) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: '8' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-coverage-gradle- + + - name: Run tests with coverage + run: ./gradlew test jacocoTestReport + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-pr + path: '**/build/reports/jacoco/test/jacocoTestReport.xml' + retention-days: 1 + + coverage-base: + name: Coverage (Base Branch) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: '8' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-coverage-gradle- + + - name: Run tests with coverage + run: ./gradlew test jacocoTestReport + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + with: + name: coverage-base + path: '**/build/reports/jacoco/test/jacocoTestReport.xml' + retention-days: 1 + + coverage-compare: + name: Compare Coverage + needs: [ coverage-pr, coverage-base ] + runs-on: ubuntu-latest + if: always() && needs.coverage-pr.result == 'success' + + steps: + - name: Download PR coverage + uses: actions/download-artifact@v4 + with: + name: coverage-pr + path: coverage-pr + + - name: Download base coverage + uses: actions/download-artifact@v4 + with: + name: coverage-base + path: coverage-base + continue-on-error: true + + - name: Compare coverage + id: compare + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // --- JaCoCo XML Parser --- + function parseCounter(xml, type) { + const regex = new RegExp(``); + const match = xml.match(regex); + if (!match) return null; + const missed = parseInt(match[1], 10); + const covered = parseInt(match[2], 10); + const total = missed + covered; + return { missed, covered, total, pct: total > 0 ? (covered / total * 100) : 0 }; + } + + function parseJacocoXml(xmlContent) { + return { + instruction: parseCounter(xmlContent, 'INSTRUCTION'), + branch: parseCounter(xmlContent, 'BRANCH'), + line: parseCounter(xmlContent, 'LINE'), + method: parseCounter(xmlContent, 'METHOD'), + class: parseCounter(xmlContent, 'CLASS'), + }; + } + + // --- Find all JaCoCo XML reports --- + function findReports(dir) { + const reports = {}; + if (!fs.existsSync(dir)) return reports; + + function walk(d) { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name === 'jacocoTestReport.xml') { + // Extract module name from path + const rel = path.relative(dir, full); + const module = rel.split(path.sep)[0]; + reports[module] = fs.readFileSync(full, 'utf8'); + } + } + } + walk(dir); + return reports; + } + + // --- Aggregate coverage across modules --- + function aggregateCoverage(reportsMap) { + const types = ['instruction', 'branch', 'line', 'method', 'class']; + const agg = {}; + for (const t of types) { + agg[t] = { missed: 0, covered: 0, total: 0, pct: 0 }; + } + + for (const [mod, xml] of Object.entries(reportsMap)) { + const parsed = parseJacocoXml(xml); + for (const t of types) { + if (parsed[t]) { + agg[t].missed += parsed[t].missed; + agg[t].covered += parsed[t].covered; + agg[t].total += parsed[t].total; + } + } + } + for (const t of types) { + agg[t].pct = agg[t].total > 0 ? (agg[t].covered / agg[t].total * 100) : 0; + } + return agg; + } + + // --- Per-module coverage --- + function perModuleCoverage(reportsMap) { + const result = {}; + for (const [mod, xml] of Object.entries(reportsMap)) { + result[mod] = parseJacocoXml(xml); + } + return result; + } + + // --- Format helpers --- + function fmtPct(val) { + return val != null ? val.toFixed(2) + '%' : 'N/A'; + } + + function diffIcon(diff) { + if (diff > 0.01) return '🟢'; + if (diff < -0.01) return '🔴'; + return '⚪'; + } + + function fmtDiff(diff) { + if (diff == null) return 'N/A'; + const sign = diff >= 0 ? '+' : ''; + return `${sign}${diff.toFixed(2)}%`; + } + + // --- Main --- + const prReports = findReports('coverage-pr'); + const baseReports = findReports('coverage-base'); + const hasBase = Object.keys(baseReports).length > 0; + + const prAgg = aggregateCoverage(prReports); + const baseAgg = hasBase ? aggregateCoverage(baseReports) : null; + + const prModules = perModuleCoverage(prReports); + const baseModules = hasBase ? perModuleCoverage(baseReports) : null; + + // --- Build Summary Table --- + let body = '### 📊 Code Coverage Report\n\n'; + + // Overall summary + body += '#### Overall Coverage\n\n'; + body += '| Metric | '; + if (hasBase) body += 'Base | '; + body += 'PR | '; + if (hasBase) body += 'Diff | '; + body += '\n'; + body += '| --- | '; + if (hasBase) body += '--- | '; + body += '--- | '; + if (hasBase) body += '--- | '; + body += '\n'; + + const metrics = [ + ['Line', 'line'], + ['Branch', 'branch'], + ['Instruction', 'instruction'], + ['Method', 'method'], + ]; + + let coverageDrop = 0; + + for (const [label, key] of metrics) { + const prVal = prAgg[key].pct; + body += `| ${label} | `; + if (hasBase) { + const baseVal = baseAgg[key].pct; + const diff = prVal - baseVal; + if (key === 'line') coverageDrop = diff; + body += `${fmtPct(baseVal)} | `; + body += `${fmtPct(prVal)} | `; + body += `${diffIcon(diff)} ${fmtDiff(diff)} | `; + } else { + body += `${fmtPct(prVal)} | `; + } + body += '\n'; + } + + // Per-module breakdown + const allModules = [...new Set([...Object.keys(prModules), ...(baseModules ? Object.keys(baseModules) : [])])].sort(); + + if (allModules.length > 1) { + body += '\n
\n📦 Per-Module Coverage (Line)\n\n'; + body += '| Module | '; + if (hasBase) body += 'Base | '; + body += 'PR | '; + if (hasBase) body += 'Diff | '; + body += '\n'; + body += '| --- | '; + if (hasBase) body += '--- | '; + body += '--- | '; + if (hasBase) body += '--- | '; + body += '\n'; + + for (const mod of allModules) { + const prLine = prModules[mod]?.line; + const baseLine = baseModules?.[mod]?.line; + const prPct = prLine ? prLine.pct : null; + const basePct = baseLine ? baseLine.pct : null; + + body += `| \`${mod}\` | `; + if (hasBase) { + body += `${basePct != null ? fmtPct(basePct) : 'N/A'} | `; + body += `${prPct != null ? fmtPct(prPct) : 'N/A'} | `; + if (prPct != null && basePct != null) { + const diff = prPct - basePct; + body += `${diffIcon(diff)} ${fmtDiff(diff)} | `; + } else { + body += 'N/A | '; + } + } else { + body += `${prPct != null ? fmtPct(prPct) : 'N/A'} | `; + } + body += '\n'; + } + body += '\n
\n'; + } + + // --- Threshold check --- + const threshold = parseFloat('${{ env.COVERAGE_DROP_THRESHOLD }}'); + let passed = true; + + if (hasBase && coverageDrop < -threshold) { + passed = false; + body += `\n> [!CAUTION]\n> Line coverage dropped by **${fmtDiff(coverageDrop)}**, exceeding the allowed threshold of **-${threshold}%**.\n`; + body += `> Please add tests to cover the new or modified code.\n`; + } else if (hasBase && coverageDrop < 0) { + body += `\n> [!WARNING]\n> Line coverage decreased by **${fmtDiff(coverageDrop)}**, but within the allowed threshold of **-${threshold}%**.\n`; + } else if (!hasBase) { + body += `\n> [!NOTE]\n> Base branch coverage is unavailable. Only PR branch coverage is shown.\n`; + } else { + body += `\n> [!TIP]\n> Coverage is stable or improved. Great job! 🎉\n`; + } + + core.setOutput('report', body); + core.setOutput('passed', passed.toString()); + core.setOutput('coverage_drop', coverageDrop.toFixed(2)); + + - name: Find existing comment + id: find-comment + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const marker = '### 📊 Code Coverage Report'; + const existing = comments.data.find(c => c.body.includes(marker)); + core.setOutput('comment_id', existing ? existing.id.toString() : ''); + + - name: Post or update PR comment + uses: actions/github-script@v7 + with: + script: | + const body = `${{ steps.compare.outputs.report }}`; + const commentId = '${{ steps.find-comment.outputs.comment_id }}'; + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: parseInt(commentId), + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Check coverage threshold + if: steps.compare.outputs.passed == 'false' + run: | + echo "::error::Line coverage dropped by ${{ steps.compare.outputs.coverage_drop }}%, exceeding the threshold of -${{ env.COVERAGE_DROP_THRESHOLD }}%" + exit 1 From 901c881d2adca64a995ee15a2f91d0e2b704bb84 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 17:42:39 +0800 Subject: [PATCH 2/8] fix(ci): write coverage report to file to avoid template literal conflict --- .github/workflows/coverage-check.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index 86e62ca0fd..0aa5e2e14c 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -316,7 +316,7 @@ jobs: body += `\n> [!TIP]\n> Coverage is stable or improved. Great job! 🎉\n`; } - core.setOutput('report', body); + fs.writeFileSync('coverage-report.md', body); core.setOutput('passed', passed.toString()); core.setOutput('coverage_drop', coverageDrop.toFixed(2)); @@ -338,7 +338,8 @@ jobs: uses: actions/github-script@v7 with: script: | - const body = `${{ steps.compare.outputs.report }}`; + const fs = require('fs'); + const body = fs.readFileSync('coverage-report.md', 'utf8'); const commentId = '${{ steps.find-comment.outputs.comment_id }}'; if (commentId) { From b1cd31ec1384edf2093fdf010b03f9e3c1035c0e Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 18:06:12 +0800 Subject: [PATCH 3/8] fix(ci): split test and jacocoTestReport to ensure exec data exists --- .github/workflows/coverage-check.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index 0aa5e2e14c..ddec71cac1 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -42,8 +42,11 @@ jobs: key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-coverage-gradle- - - name: Run tests with coverage - run: ./gradlew test jacocoTestReport + - name: Run tests + run: ./gradlew test + + - name: Generate coverage reports + run: ./gradlew jacocoTestReport - name: Upload coverage reports uses: actions/upload-artifact@v4 @@ -77,8 +80,11 @@ jobs: key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-coverage-gradle- - - name: Run tests with coverage - run: ./gradlew test jacocoTestReport + - name: Run tests + run: ./gradlew test + + - name: Generate coverage reports + run: ./gradlew jacocoTestReport - name: Upload coverage reports uses: actions/upload-artifact@v4 From 7b706954307d6df847b528bec81cbdc800836947 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 18:08:41 +0800 Subject: [PATCH 4/8] fix(ci): parse report-level JaCoCo counters instead of first match --- .github/workflows/coverage-check.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index ddec71cac1..604b65aa3d 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -122,12 +122,18 @@ jobs: const path = require('path'); // --- JaCoCo XML Parser --- + // Use the last match of each counter type, which is the report-level summary. + // JaCoCo XML nests counters at method → class → package → report level. function parseCounter(xml, type) { - const regex = new RegExp(``); - const match = xml.match(regex); - if (!match) return null; - const missed = parseInt(match[1], 10); - const covered = parseInt(match[2], 10); + const regex = new RegExp(``, 'g'); + let match; + let last = null; + while ((match = regex.exec(xml)) !== null) { + last = match; + } + if (!last) return null; + const missed = parseInt(last[1], 10); + const covered = parseInt(last[2], 10); const total = missed + covered; return { missed, covered, total, pct: total > 0 ? (covered / total * 100) : 0 }; } From 75bde18519ce02ed89cf1497fa7b52f049efd6a2 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 18:42:27 +0800 Subject: [PATCH 5/8] fix(ci): increase diff icon threshold to 0.1% to filter noise --- .github/workflows/coverage-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index 604b65aa3d..8d69eefa82 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -209,8 +209,8 @@ jobs: } function diffIcon(diff) { - if (diff > 0.01) return '🟢'; - if (diff < -0.01) return '🔴'; + if (diff > 0.1) return '🟢'; + if (diff < -0.1) return '🔴'; return '⚪'; } From 07a80f3fb54d4bc8156c01dbd8de79a90f0d0fd6 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 22:21:04 +0800 Subject: [PATCH 6/8] ci: merge test and jacocoTestReport into single Gradle invocation Reduces Gradle startup overhead by running both tasks in one command. --- .github/workflows/coverage-check.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index 8d69eefa82..42096e4be8 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -42,11 +42,8 @@ jobs: key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-coverage-gradle- - - name: Run tests - run: ./gradlew test - - - name: Generate coverage reports - run: ./gradlew jacocoTestReport + - name: Run tests and generate coverage reports + run: ./gradlew test jacocoTestReport - name: Upload coverage reports uses: actions/upload-artifact@v4 @@ -80,11 +77,8 @@ jobs: key: ${{ runner.os }}-coverage-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-coverage-gradle- - - name: Run tests - run: ./gradlew test - - - name: Generate coverage reports - run: ./gradlew jacocoTestReport + - name: Run tests and generate coverage reports + run: ./gradlew test jacocoTestReport - name: Upload coverage reports uses: actions/upload-artifact@v4 From 8eecc1f9fc3d02bfef5ec76a89ca00186d1c3b5b Mon Sep 17 00:00:00 2001 From: vividcoder Date: Thu, 5 Mar 2026 22:33:40 +0800 Subject: [PATCH 7/8] ci: adjust coverage thresholds to 5% fail and 3% warn Co-Authored-By: Claude Opus 4.6 --- .github/workflows/coverage-check.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index 42096e4be8..be94e46dc7 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -15,7 +15,9 @@ concurrency: env: # Fail the check if total coverage drops more than this percentage - COVERAGE_DROP_THRESHOLD: 1.0 + COVERAGE_DROP_THRESHOLD: 5.0 + # Warn if coverage drops more than this percentage + COVERAGE_WARN_THRESHOLD: 3.0 jobs: # Run tests on PR branch and base branch in parallel @@ -308,14 +310,18 @@ jobs: // --- Threshold check --- const threshold = parseFloat('${{ env.COVERAGE_DROP_THRESHOLD }}'); + const warnThreshold = parseFloat('${{ env.COVERAGE_WARN_THRESHOLD }}'); let passed = true; if (hasBase && coverageDrop < -threshold) { passed = false; body += `\n> [!CAUTION]\n> Line coverage dropped by **${fmtDiff(coverageDrop)}**, exceeding the allowed threshold of **-${threshold}%**.\n`; body += `> Please add tests to cover the new or modified code.\n`; + } else if (hasBase && coverageDrop < -warnThreshold) { + body += `\n> [!WARNING]\n> Line coverage decreased by **${fmtDiff(coverageDrop)}**, exceeding the warning threshold of **-${warnThreshold}%**.\n`; + body += `> Consider adding tests to cover the new or modified code.\n`; } else if (hasBase && coverageDrop < 0) { - body += `\n> [!WARNING]\n> Line coverage decreased by **${fmtDiff(coverageDrop)}**, but within the allowed threshold of **-${threshold}%**.\n`; + body += `\n> [!NOTE]\n> Line coverage decreased by **${fmtDiff(coverageDrop)}**, within the allowed threshold.\n`; } else if (!hasBase) { body += `\n> [!NOTE]\n> Base branch coverage is unavailable. Only PR branch coverage is shown.\n`; } else { From eaf8d147a46ac8971e78cc70a2a82106ed677a77 Mon Sep 17 00:00:00 2001 From: vividcoder Date: Fri, 6 Mar 2026 00:24:01 +0800 Subject: [PATCH 8/8] fix(ci): use pull_request_target to fix fork PR permission issue Switch from pull_request to pull_request_target event so the workflow token has write permission to post PR comments on fork PRs. Add repository and ref parameters to checkout the PR head code correctly. --- .github/workflows/coverage-check.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage-check.yml b/.github/workflows/coverage-check.yml index be94e46dc7..29d37bc11f 100644 --- a/.github/workflows/coverage-check.yml +++ b/.github/workflows/coverage-check.yml @@ -1,7 +1,7 @@ name: Coverage Check on: - pull_request: + pull_request_target: branches: [ 'develop', 'release_**' ] types: [ opened, synchronize, reopened ] @@ -28,6 +28,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} - name: Set up JDK 8 uses: actions/setup-java@v4