Skip to content

Commit 4771fa2

Browse files
authored
Basic coverage tool (#862)
* Basic coverage tool * coverage summary with diff * show coverage link * upload to temp hosting * ez html view * coverage url debug * debug * change upload provider * no external html view * coverage viewer drag-and-drop * lint * setup-go * better usability * maybe artifact.ai
1 parent e3d485d commit 4771fa2

File tree

7 files changed

+1074
-3
lines changed

7 files changed

+1074
-3
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
name: 'Generate Coverage Report'
2+
description: 'Merge and report Go test coverage from multiple test runs'
3+
author: 'Curio Team'
4+
5+
inputs:
6+
artifacts-dir:
7+
description: 'Directory containing coverage artifacts'
8+
required: false
9+
default: 'coverage-artifacts'
10+
output-dir:
11+
description: 'Directory for output coverage files'
12+
required: false
13+
default: 'coverage'
14+
github-token:
15+
description: 'GitHub token for creating checks and comments'
16+
required: true
17+
create-check:
18+
description: 'Create a GitHub Check for coverage'
19+
required: false
20+
default: 'true'
21+
create-comment:
22+
description: 'Create/update PR comment with coverage'
23+
required: false
24+
default: 'true'
25+
minimum-coverage:
26+
description: 'Minimum acceptable coverage percentage'
27+
required: false
28+
default: '0'
29+
coverage-html-url:
30+
description: 'Direct URL to view coverage HTML (if uploaded to external host)'
31+
required: false
32+
default: ''
33+
34+
outputs:
35+
total-coverage:
36+
description: 'Total coverage percentage'
37+
value: ${{ steps.coverage.outputs.coverage }}
38+
badge-color:
39+
description: 'Badge color based on coverage'
40+
value: ${{ steps.coverage.outputs.badge-color }}
41+
coverage-file:
42+
description: 'Path to merged coverage file'
43+
value: ${{ steps.coverage.outputs.coverage-file }}
44+
has-baseline:
45+
description: 'Whether baseline comparison is available'
46+
value: ${{ steps.coverage.outputs.has-baseline }}
47+
baseline-coverage:
48+
description: 'Baseline coverage percentage from main'
49+
value: ${{ steps.coverage.outputs.baseline-coverage }}
50+
coverage-diff:
51+
description: 'Coverage difference from baseline (+/- percentage)'
52+
value: ${{ steps.coverage.outputs.coverage-diff }}
53+
baseline-commit:
54+
description: 'Git commit SHA of baseline'
55+
value: ${{ steps.coverage.outputs.baseline-commit }}
56+
57+
runs:
58+
using: 'composite'
59+
steps:
60+
- name: Download baseline coverage
61+
if: github.event_name == 'pull_request'
62+
continue-on-error: true
63+
uses: dawidd6/action-download-artifact@v6
64+
with:
65+
workflow: coverage-baseline.yml
66+
branch: main
67+
name: coverage-baseline-main
68+
path: baseline-coverage
69+
github_token: ${{ inputs.github-token }}
70+
71+
- name: Generate coverage report
72+
id: coverage
73+
shell: bash
74+
run: |
75+
export COVERAGE_DIR="${{ inputs.output-dir }}"
76+
export ARTIFACTS_DIR="${{ inputs.artifacts-dir }}"
77+
chmod +x "$GITHUB_ACTION_PATH/../../utils/coverage-report.sh"
78+
"$GITHUB_ACTION_PATH/../../utils/coverage-report.sh"
79+
80+
# Set outputs
81+
COVERAGE_TOTAL=$(cat ${COVERAGE_DIR}/coverage.json | grep -o '"total_coverage": "[^"]*"' | cut -d'"' -f4)
82+
BADGE_COLOR=$(cat ${COVERAGE_DIR}/coverage.json | grep -o '"badge_color": "[^"]*"' | cut -d'"' -f4)
83+
84+
echo "coverage=${COVERAGE_TOTAL}" >> $GITHUB_OUTPUT
85+
echo "badge-color=${BADGE_COLOR}" >> $GITHUB_OUTPUT
86+
echo "coverage-file=${COVERAGE_DIR}/merged.out" >> $GITHUB_OUTPUT
87+
88+
# Calculate diff if baseline exists
89+
if [ -f "baseline-coverage/baseline.json" ]; then
90+
BASELINE_COV=$(cat baseline-coverage/baseline.json | grep -o '"coverage_percentage": [0-9.]*' | cut -d' ' -f2)
91+
CURRENT_COV=$(cat ${COVERAGE_DIR}/coverage.json | grep -o '"coverage_percentage": [0-9.]*' | cut -d' ' -f2)
92+
BASELINE_COMMIT=$(cat baseline-coverage/baseline.json | grep -o '"commit": "[^"]*"' | cut -d'"' -f4 | cut -c1-7)
93+
94+
DIFF=$(echo "$CURRENT_COV - $BASELINE_COV" | bc)
95+
96+
echo "baseline-coverage=${BASELINE_COV}%" >> $GITHUB_OUTPUT
97+
echo "coverage-diff=${DIFF}%" >> $GITHUB_OUTPUT
98+
echo "baseline-commit=${BASELINE_COMMIT}" >> $GITHUB_OUTPUT
99+
echo "has-baseline=true" >> $GITHUB_OUTPUT
100+
101+
# Add diff info to coverage JSON
102+
cat ${COVERAGE_DIR}/coverage.json | jq \
103+
--arg diff "$DIFF" \
104+
--arg baseline "$BASELINE_COV" \
105+
--arg commit "$BASELINE_COMMIT" \
106+
'. + {comparison: {baseline_coverage: ($baseline + "%"), diff: ($diff + "%"), baseline_commit: $commit}}' \
107+
> ${COVERAGE_DIR}/coverage-with-diff.json
108+
mv ${COVERAGE_DIR}/coverage-with-diff.json ${COVERAGE_DIR}/coverage.json
109+
else
110+
echo "has-baseline=false" >> $GITHUB_OUTPUT
111+
echo "No baseline coverage found - this is expected for first run or on main branch" >> $GITHUB_STEP_SUMMARY
112+
fi
113+
114+
- name: Check minimum coverage
115+
shell: bash
116+
run: |
117+
COVERAGE_NUM=$(echo "${{ steps.coverage.outputs.coverage }}" | sed 's/%//')
118+
MIN_COV="${{ inputs.minimum-coverage }}"
119+
120+
if (( $(echo "$COVERAGE_NUM < $MIN_COV" | bc -l) )); then
121+
echo "::error::Coverage ${COVERAGE_NUM}% is below minimum required ${MIN_COV}%"
122+
exit 1
123+
fi
124+
125+
- name: Create GitHub Check
126+
if: inputs.create-check == 'true' && github.event_name == 'pull_request'
127+
uses: actions/github-script@v7
128+
with:
129+
github-token: ${{ inputs.github-token }}
130+
script: |
131+
const fs = require('fs');
132+
const coverageText = fs.readFileSync('${{ inputs.output-dir }}/coverage.txt', 'utf8');
133+
const coverage = '${{ steps.coverage.outputs.coverage }}';
134+
const hasBaseline = '${{ steps.coverage.outputs.has-baseline }}' === 'true';
135+
const baselineCov = '${{ steps.coverage.outputs.baseline-coverage }}';
136+
const diff = '${{ steps.coverage.outputs.coverage-diff }}';
137+
const baselineCommit = '${{ steps.coverage.outputs.baseline-commit }}';
138+
139+
// Extract summary data
140+
const lines = coverageText.split('\n').filter(line => line.trim());
141+
const totalLine = lines[lines.length - 1];
142+
const functionLines = lines.slice(0, -1);
143+
144+
// Build comparison section if baseline exists
145+
let comparisonSection = '';
146+
if (hasBaseline) {
147+
const diffNum = parseFloat(diff);
148+
const diffEmoji = diffNum > 0 ? '📈' : diffNum < 0 ? '📉' : '➡️';
149+
const diffSign = diffNum > 0 ? '+' : '';
150+
comparisonSection = `\n\n### ${diffEmoji} Coverage Change\n\n**Baseline (main):** ${baselineCov} (commit \`${baselineCommit}\`)\n**This PR:** ${coverage}\n**Difference:** ${diffSign}${diff}\n`;
151+
}
152+
153+
// Create a summary that fits within GitHub's 65k character limit
154+
// Use artifact.ci to view the coverage HTML directly
155+
const artifactCiUrl = `https://artifact.ci/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/coverage-merged/coverage.html`;
156+
157+
let htmlLink = `\n\n### 📊 [View Coverage Report](${artifactCiUrl})\n\n`;
158+
159+
const summary = `## 📊 Test Coverage Report\n\n**Total Coverage:** ${coverage}${comparisonSection}${htmlLink}**Summary:** ${totalLine}\n\n*Coverage includes ${functionLines.length} functions*`;
160+
161+
await github.rest.checks.create({
162+
owner: context.repo.owner,
163+
repo: context.repo.repo,
164+
name: 'Code Coverage Report',
165+
head_sha: context.payload.pull_request.head.sha,
166+
status: 'completed',
167+
conclusion: 'success',
168+
output: {
169+
title: `Total Coverage: ${coverage}${hasBaseline ? ` (${parseFloat(diff) > 0 ? '+' : ''}${diff})` : ''}`,
170+
summary: summary.substring(0, 65000), // Ensure it fits
171+
text: `View the interactive coverage report:\n${artifactCiUrl}\n\nTotal functions: ${functionLines.length}`
172+
}
173+
});

.github/utils/coverage-report.sh

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/bin/bash
2+
# Coverage Report Generator for Curio
3+
# This script merges multiple Go coverage profiles and generates reports
4+
5+
set -e
6+
7+
COVERAGE_DIR="${COVERAGE_DIR:-coverage}"
8+
ARTIFACTS_DIR="${ARTIFACTS_DIR:-coverage-artifacts}"
9+
OUTPUT_FILE="${OUTPUT_FILE:-${COVERAGE_DIR}/merged.out}"
10+
SUMMARY_FILE="${SUMMARY_FILE:-${COVERAGE_DIR}/coverage.txt}"
11+
HTML_FILE="${HTML_FILE:-${COVERAGE_DIR}/coverage.html}"
12+
13+
# Colors for output
14+
GREEN='\033[0;32m'
15+
YELLOW='\033[1;33m'
16+
RED='\033[0;31m'
17+
NC='\033[0m' # No Color
18+
19+
echo "🔍 Curio Coverage Report Generator"
20+
echo "=================================="
21+
22+
# Create output directory
23+
mkdir -p "${COVERAGE_DIR}"
24+
25+
# Initialize merged coverage file with mode line
26+
echo "mode: atomic" > "${OUTPUT_FILE}"
27+
28+
# Counter for processed files
29+
PROCESSED=0
30+
31+
# Find and merge all coverage files
32+
echo "📦 Searching for coverage files in ${ARTIFACTS_DIR}..."
33+
if [ -d "${ARTIFACTS_DIR}" ]; then
34+
for dir in "${ARTIFACTS_DIR}"/coverage-*; do
35+
if [ -d "$dir" ]; then
36+
for file in "$dir"/*.out; do
37+
if [ -f "$file" ]; then
38+
echo " ✓ Processing $(basename "$file")"
39+
# Skip the mode line and append the rest
40+
tail -n +2 "$file" >> "${OUTPUT_FILE}"
41+
PROCESSED=$((PROCESSED + 1))
42+
fi
43+
done
44+
fi
45+
done
46+
else
47+
echo "⚠️ Warning: Artifacts directory not found: ${ARTIFACTS_DIR}"
48+
# Try to find coverage files in current directory
49+
for file in *.out; do
50+
if [ -f "$file" ] && [ "$file" != "merged.out" ]; then
51+
echo " ✓ Processing $(basename "$file")"
52+
if [ $PROCESSED -eq 0 ]; then
53+
# First file - include mode line
54+
cat "$file" > "${OUTPUT_FILE}"
55+
else
56+
# Subsequent files - skip mode line
57+
tail -n +2 "$file" >> "${OUTPUT_FILE}"
58+
fi
59+
PROCESSED=$((PROCESSED + 1))
60+
fi
61+
done
62+
fi
63+
64+
if [ $PROCESSED -eq 0 ]; then
65+
echo "❌ No coverage files found!"
66+
exit 1
67+
fi
68+
69+
echo "✅ Merged $PROCESSED coverage file(s)"
70+
71+
# Generate function-level coverage report
72+
echo ""
73+
echo "📊 Generating coverage summary..."
74+
go tool cover -func="${OUTPUT_FILE}" > "${SUMMARY_FILE}"
75+
76+
# Extract total coverage
77+
TOTAL_COVERAGE=$(grep "total:" "${SUMMARY_FILE}" | awk '{print $3}')
78+
COVERAGE_NUM=$(echo "${TOTAL_COVERAGE}" | sed 's/%//')
79+
80+
echo ""
81+
echo "=================================="
82+
if (( $(echo "$COVERAGE_NUM >= 80" | bc -l) )); then
83+
echo -e "${GREEN}✅ Total Coverage: ${TOTAL_COVERAGE}${NC}"
84+
BADGE_COLOR="brightgreen"
85+
elif (( $(echo "$COVERAGE_NUM >= 60" | bc -l) )); then
86+
echo -e "${YELLOW}⚠️ Total Coverage: ${TOTAL_COVERAGE}${NC}"
87+
BADGE_COLOR="yellow"
88+
else
89+
echo -e "${RED}❌ Total Coverage: ${TOTAL_COVERAGE}${NC}"
90+
BADGE_COLOR="red"
91+
fi
92+
echo "=================================="
93+
94+
# Generate HTML report
95+
echo ""
96+
echo "📄 Generating HTML report..."
97+
go tool cover -html="${OUTPUT_FILE}" -o "${HTML_FILE}"
98+
echo " ✓ HTML report: ${HTML_FILE}"
99+
100+
# Display top 10 packages by coverage
101+
echo ""
102+
echo "📈 Top Functions by Coverage:"
103+
echo "----------------------------"
104+
head -n -1 "${SUMMARY_FILE}" | tail -n +2 | sort -k3 -rn | head -10 | \
105+
awk '{printf " %s: %s\n", $3, $1}'
106+
107+
# Display bottom 10 packages (needing improvement)
108+
echo ""
109+
echo "⚠️ Functions Needing Coverage Improvement:"
110+
echo "-------------------------------------------"
111+
head -n -1 "${SUMMARY_FILE}" | tail -n +2 | sort -k3 -n | head -10 | \
112+
awk '{printf " %s: %s\n", $3, $1}'
113+
114+
# Generate package-level summary
115+
echo ""
116+
echo "📦 Generating package-level summary..."
117+
PACKAGE_SUMMARY="${COVERAGE_DIR}/packages.txt"
118+
head -n -1 "${SUMMARY_FILE}" | tail -n +2 | \
119+
awk -F: '{
120+
# Extract package path (everything before the last colon with line number)
121+
pkg = $1
122+
# Extract coverage percentage from the last field
123+
match($0, /([0-9.]+%)[[:space:]]*$/, arr)
124+
coverage = arr[1]
125+
gsub(/%/, "", coverage)
126+
127+
if (pkg != "" && coverage != "") {
128+
sum[pkg] += coverage
129+
count[pkg]++
130+
}
131+
}
132+
END {
133+
for (pkg in sum) {
134+
avg = sum[pkg] / count[pkg]
135+
printf "%s: %.1f%% (%d functions)\n", pkg, avg, count[pkg]
136+
}
137+
}' | sort -t: -k2 -rn > "${PACKAGE_SUMMARY}"
138+
139+
echo " ✓ Package summary: ${PACKAGE_SUMMARY}"
140+
141+
# Show top 10 packages
142+
echo ""
143+
echo "📊 Top 10 Packages by Average Coverage:"
144+
echo "---------------------------------------"
145+
head -10 "${PACKAGE_SUMMARY}"
146+
147+
# Export coverage info for CI
148+
if [ -n "$GITHUB_ENV" ]; then
149+
echo "COVERAGE_TOTAL=${TOTAL_COVERAGE}" >> "$GITHUB_ENV"
150+
echo "BADGE_COLOR=${BADGE_COLOR}" >> "$GITHUB_ENV"
151+
echo "COVERAGE_NUM=${COVERAGE_NUM}" >> "$GITHUB_ENV"
152+
fi
153+
154+
# Count total functions and packages
155+
TOTAL_FUNCTIONS=$(head -n -1 "${SUMMARY_FILE}" | tail -n +2 | wc -l | tr -d ' ')
156+
TOTAL_PACKAGES=$(wc -l < "${PACKAGE_SUMMARY}" | tr -d ' ')
157+
158+
# Generate JSON report for programmatic use
159+
echo ""
160+
echo "💾 Generating JSON report..."
161+
cat > "${COVERAGE_DIR}/coverage.json" <<EOF
162+
{
163+
"total_coverage": "${TOTAL_COVERAGE}",
164+
"coverage_percentage": ${COVERAGE_NUM},
165+
"badge_color": "${BADGE_COLOR}",
166+
"files_processed": ${PROCESSED},
167+
"total_functions": ${TOTAL_FUNCTIONS},
168+
"total_packages": ${TOTAL_PACKAGES},
169+
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
170+
"report_files": {
171+
"merged": "${OUTPUT_FILE}",
172+
"summary": "${SUMMARY_FILE}",
173+
"packages": "${PACKAGE_SUMMARY}",
174+
"html": "${HTML_FILE}"
175+
}
176+
}
177+
EOF
178+
echo " ✓ JSON report: ${COVERAGE_DIR}/coverage.json"
179+
180+
echo ""
181+
echo "✅ Coverage report generation complete!"
182+

0 commit comments

Comments
 (0)