Skip to content

Commit 223a935

Browse files
CopilotMossaka
andauthored
feat(ci): add coverage regression detection (#244)
* Initial plan * feat(ci): add coverage regression detection Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
1 parent 9e063a0 commit 223a935

2 files changed

Lines changed: 400 additions & 48 deletions

File tree

.github/workflows/test-coverage.yml

Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ jobs:
1717
coverage:
1818
name: Test Coverage Report
1919
runs-on: ubuntu-latest
20-
timeout-minutes: 10
20+
timeout-minutes: 15
2121

2222
steps:
2323
- name: Checkout repository
2424
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
25+
with:
26+
fetch-depth: 0
2527

2628
- name: Setup Node.js
2729
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -35,21 +37,65 @@ jobs:
3537
- name: Build project
3638
run: npm run build
3739

38-
- name: Run tests with coverage
40+
- name: Run tests with coverage (PR branch)
3941
run: npm run test:coverage
4042

41-
- name: Generate coverage summary
43+
- name: Save PR coverage
44+
run: cp coverage/coverage-summary.json /tmp/pr-coverage-summary.json
45+
46+
- name: Get base branch coverage (PR only)
47+
if: github.event_name == 'pull_request'
48+
id: base_coverage
49+
run: |
50+
# Save the current commit
51+
PR_COMMIT=$(git rev-parse HEAD)
52+
53+
# Checkout base branch
54+
git checkout ${{ github.event.pull_request.base.sha }}
55+
56+
# Install dependencies and build for base branch
57+
npm ci
58+
npm run build
59+
60+
# Run coverage on base branch
61+
npm run test:coverage || true
62+
63+
# Save base coverage
64+
if [ -f coverage/coverage-summary.json ]; then
65+
cp coverage/coverage-summary.json /tmp/base-coverage-summary.json
66+
echo "base_coverage_exists=true" >> $GITHUB_OUTPUT
67+
else
68+
echo "base_coverage_exists=false" >> $GITHUB_OUTPUT
69+
fi
70+
71+
# Checkout back to PR commit
72+
git checkout $PR_COMMIT
73+
74+
# Reinstall PR dependencies
75+
npm ci
76+
77+
- name: Compare coverage (PR only)
78+
if: github.event_name == 'pull_request' && steps.base_coverage.outputs.base_coverage_exists == 'true'
79+
id: compare
80+
run: |
81+
npx tsx scripts/ci/compare-coverage.ts \
82+
/tmp/pr-coverage-summary.json \
83+
/tmp/base-coverage-summary.json
84+
continue-on-error: true
85+
86+
- name: Generate coverage summary (push to main)
87+
if: github.event_name == 'push'
4288
id: coverage
4389
run: |
4490
# Read the coverage summary
4591
COVERAGE_JSON=$(cat coverage/coverage-summary.json)
46-
92+
4793
# Extract metrics using jq
4894
LINES_PCT=$(echo "$COVERAGE_JSON" | jq -r '.total.lines.pct')
4995
STATEMENTS_PCT=$(echo "$COVERAGE_JSON" | jq -r '.total.statements.pct')
5096
FUNCTIONS_PCT=$(echo "$COVERAGE_JSON" | jq -r '.total.functions.pct')
5197
BRANCHES_PCT=$(echo "$COVERAGE_JSON" | jq -r '.total.branches.pct')
52-
98+
5399
LINES_COVERED=$(echo "$COVERAGE_JSON" | jq -r '.total.lines.covered')
54100
LINES_TOTAL=$(echo "$COVERAGE_JSON" | jq -r '.total.lines.total')
55101
STATEMENTS_COVERED=$(echo "$COVERAGE_JSON" | jq -r '.total.statements.covered')
@@ -58,7 +104,7 @@ jobs:
58104
FUNCTIONS_TOTAL=$(echo "$COVERAGE_JSON" | jq -r '.total.functions.total')
59105
BRANCHES_COVERED=$(echo "$COVERAGE_JSON" | jq -r '.total.branches.covered')
60106
BRANCHES_TOTAL=$(echo "$COVERAGE_JSON" | jq -r '.total.branches.total')
61-
107+
62108
# Create summary for GitHub Actions Summary
63109
echo "## Test Coverage Report" >> $GITHUB_STEP_SUMMARY
64110
echo "" >> $GITHUB_STEP_SUMMARY
@@ -69,62 +115,56 @@ jobs:
69115
echo "| **Functions** | ${FUNCTIONS_PCT}% | ${FUNCTIONS_COVERED}/${FUNCTIONS_TOTAL} |" >> $GITHUB_STEP_SUMMARY
70116
echo "| **Branches** | ${BRANCHES_PCT}% | ${BRANCHES_COVERED}/${BRANCHES_TOTAL} |" >> $GITHUB_STEP_SUMMARY
71117
echo "" >> $GITHUB_STEP_SUMMARY
72-
73-
# Create PR comment body
74-
COMMENT_BODY="## Test Coverage Report
75-
76-
| Metric | Coverage | Covered/Total |
77-
|--------|----------|---------------|
78-
| **Lines** | ${LINES_PCT}% | ${LINES_COVERED}/${LINES_TOTAL} |
79-
| **Statements** | ${STATEMENTS_PCT}% | ${STATEMENTS_COVERED}/${STATEMENTS_TOTAL} |
80-
| **Functions** | ${FUNCTIONS_PCT}% | ${FUNCTIONS_COVERED}/${FUNCTIONS_TOTAL} |
81-
| **Branches** | ${BRANCHES_PCT}% | ${BRANCHES_COVERED}/${BRANCHES_TOTAL} |
82-
83-
<details>
84-
<summary>Coverage Thresholds</summary>
85-
86-
The project has the following coverage thresholds configured:
87-
- Lines: 38%
88-
- Statements: 38%
89-
- Functions: 35%
90-
- Branches: 30%
91-
92-
</details>
93-
94-
---
95-
*Coverage report generated by \\\`npm run test:coverage\\\`*"
96-
97-
# Save for next step (escape newlines for GitHub Actions)
98-
echo "COMMENT_BODY<<EOF" >> $GITHUB_ENV
99-
echo "$COMMENT_BODY" >> $GITHUB_ENV
100-
echo "EOF" >> $GITHUB_ENV
101-
118+
102119
# Also save individual metrics as outputs
103120
echo "lines_pct=${LINES_PCT}" >> $GITHUB_OUTPUT
104121
echo "statements_pct=${STATEMENTS_PCT}" >> $GITHUB_OUTPUT
105122
echo "functions_pct=${FUNCTIONS_PCT}" >> $GITHUB_OUTPUT
106123
echo "branches_pct=${BRANCHES_PCT}" >> $GITHUB_OUTPUT
107124
108-
- name: Comment PR with coverage report
125+
- name: Comment PR with coverage comparison
109126
if: github.event_name == 'pull_request'
110127
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
111128
with:
112129
github-token: ${{ secrets.GITHUB_TOKEN }}
113130
script: |
114-
const commentBody = process.env.COMMENT_BODY;
115-
131+
const fs = require('fs');
132+
133+
// Try to read the coverage report from compare step
134+
let commentBody = process.env.COVERAGE_REPORT;
135+
136+
// If no comparison report, generate a simple report
137+
if (!commentBody) {
138+
const prCoverage = JSON.parse(fs.readFileSync('/tmp/pr-coverage-summary.json', 'utf8'));
139+
const total = prCoverage.total;
140+
141+
commentBody = `## 📊 Test Coverage Report
142+
143+
| Metric | Coverage |
144+
|--------|----------|
145+
| Lines | ${total.lines.pct.toFixed(2)}% |
146+
| Statements | ${total.statements.pct.toFixed(2)}% |
147+
| Functions | ${total.functions.pct.toFixed(2)}% |
148+
| Branches | ${total.branches.pct.toFixed(2)}% |
149+
150+
> ℹ️ Base branch coverage not available for comparison.
151+
152+
---
153+
*Coverage report generated by \`npm run test:coverage\`*`;
154+
}
155+
116156
// Find existing coverage comment
117157
const { data: comments } = await github.rest.issues.listComments({
118158
owner: context.repo.owner,
119159
repo: context.repo.repo,
120160
issue_number: context.issue.number,
121161
});
122-
123-
const botComment = comments.find(comment =>
124-
comment.user.type === 'Bot' &&
125-
comment.body.includes('Test Coverage Report')
162+
163+
const botComment = comments.find(comment =>
164+
comment.user.type === 'Bot' &&
165+
(comment.body.includes('Test Coverage Report') || comment.body.includes('Coverage Check'))
126166
);
127-
167+
128168
if (botComment) {
129169
// Update existing comment
130170
await github.rest.issues.updateComment({
@@ -151,8 +191,11 @@ jobs:
151191
coverage/
152192
retention-days: 30
153193

154-
- name: Check coverage thresholds
194+
- name: Fail on coverage regression
195+
if: github.event_name == 'pull_request' && steps.compare.outcome == 'failure'
155196
run: |
156-
echo "Checking if coverage meets minimum thresholds..."
157-
# Jest will fail if coverage is below thresholds defined in jest.config.js
158-
# This step is informational since the test:coverage command already checks
197+
echo "❌ Coverage regression detected!"
198+
echo "This PR decreases overall test coverage. Please add tests to maintain coverage levels."
199+
echo ""
200+
echo "See the PR comment above for detailed coverage comparison."
201+
exit 1

0 commit comments

Comments
 (0)