Skip to content

Commit 56e421b

Browse files
authored
Merge pull request Perfect-Abstractions#150 from adamgall/pr-gas-report
feat(ci): add automated gas reporting for pull requests
2 parents 77f7a55 + 6e61b1b commit 56e421b

9 files changed

Lines changed: 610 additions & 151 deletions

.github/scripts/coverage-comment.js

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -117,58 +117,19 @@ function generateCoverageReport(metrics, commitInfo = {}) {
117117
`*Last updated: ${timestamp}*${commitLink}\n`;
118118
}
119119

120-
/**
121-
* Main function to post coverage comment
122-
* @param {object} github - GitHub API object
123-
* @param {object} context - GitHub Actions context
124-
*/
125-
async function postCoverageComment(github, context) {
126-
const file = 'lcov.info';
127-
128-
if (!fs.existsSync(file)) {
129-
console.log('Coverage file not found.');
130-
return;
131-
}
132-
133-
const content = fs.readFileSync(file, 'utf8');
134-
const metrics = parseLcovContent(content);
135-
136-
console.log('Coverage Metrics:');
137-
console.log('- Lines:', metrics.coveredLines, '/', metrics.totalLines);
138-
console.log('- Functions:', metrics.coveredFunctions, '/', metrics.totalFunctions);
139-
console.log('- Branches:', metrics.coveredBranches, '/', metrics.totalBranches);
140-
141-
const body = generateCoverageReport(metrics);
142-
143-
await github.rest.issues.createComment({
144-
owner: context.repo.owner,
145-
repo: context.repo.repo,
146-
issue_number: context.issue.number,
147-
body: body
148-
});
149-
150-
console.log('Coverage comment posted successfully!');
151-
}
152-
153120
/**
154121
* Generate coverage report and save to file (for workflow artifacts)
155122
*/
156123
function generateCoverageFile() {
157124
const file = 'lcov.info';
158125

159126
if (!fs.existsSync(file)) {
160-
console.log('Coverage file not found.');
161127
return;
162128
}
163129

164130
const content = fs.readFileSync(file, 'utf8');
165131
const metrics = parseLcovContent(content);
166132

167-
console.log('Coverage Metrics:');
168-
console.log('- Lines:', metrics.coveredLines, '/', metrics.totalLines);
169-
console.log('- Functions:', metrics.coveredFunctions, '/', metrics.totalFunctions);
170-
console.log('- Branches:', metrics.coveredBranches, '/', metrics.totalBranches);
171-
172133
// Get commit info from environment variables
173134
const commitInfo = {
174135
sha: process.env.COMMIT_SHA,
@@ -178,13 +139,9 @@ function generateCoverageFile() {
178139

179140
const body = generateCoverageReport(metrics, commitInfo);
180141
fs.writeFileSync('coverage-report.md', body);
181-
console.log('Coverage report saved to coverage-report.md');
182142
}
183143

184144
// If run directly (not as module), generate the file
185145
if (require.main === module) {
186146
generateCoverageFile();
187147
}
188-
189-
module.exports = { postCoverageComment, generateCoverageFile };
190-

.github/scripts/gas-report.js

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* Gas Report Script for GitHub Actions
3+
* Handles forge snapshot diffs and generates formatted reports
4+
*
5+
* Usage:
6+
* - node gas-report.js generate - Read gas-diff.txt and create gas-report.md
7+
* - Called from gas-report.yml workflow to generate report for artifact upload
8+
*/
9+
10+
const fs = require('fs');
11+
const path = require('path');
12+
13+
/**
14+
* Parse gas diff output into structured data
15+
*/
16+
function parseGasDiff(diffOutput) {
17+
const lines = diffOutput.split('\n').filter(line => line.trim());
18+
const changes = [];
19+
20+
for (const line of lines) {
21+
// Skip lines until we find the actual diff output
22+
// Look for lines like: test_FunctionName() (gas: -123 (-1.23%))
23+
// or: testFuzz_Name(params) (gas: 456 (4.56%))
24+
const trimmedLine = line.trim();
25+
26+
// Match function name followed by (gas: change (percentage))
27+
const changeMatch = trimmedLine.match(/^([\w_]+)\([^)]*\)\s+\(gas:\s*([-+]?\d+)\s+\([^)]+\)\)/);
28+
29+
if (changeMatch) {
30+
const [, funcName, gasChangeStr] = changeMatch;
31+
const gasChange = parseInt(gasChangeStr);
32+
33+
// Skip zero changes
34+
if (gasChange === 0) continue;
35+
36+
changes.push({
37+
type: gasChange < 0 ? 'improvement' : 'regression',
38+
contract: 'Test', // We don't have contract names in this format
39+
function: funcName,
40+
oldGas: null, // We don't have old value
41+
newGas: null, // We don't have new value
42+
gasChange: gasChange
43+
});
44+
}
45+
}
46+
47+
return changes;
48+
}
49+
50+
/**
51+
* Format large numbers with commas
52+
*/
53+
function formatGasValue(value) {
54+
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
55+
}
56+
57+
/**
58+
* Generate markdown table from changes
59+
*/
60+
function generateMarkdownTable(changes, limit = 20) {
61+
if (changes.length === 0) {
62+
return 'No gas changes detected in individual functions.';
63+
}
64+
65+
// Sort by absolute gas change (biggest changes first)
66+
changes.sort((a, b) => Math.abs(b.gasChange) - Math.abs(a.gasChange));
67+
68+
let table = `| Contract | Function | Before | After | Change |
69+
|----------|----------|--------|-------|--------|
70+
`;
71+
72+
const displayChanges = changes.slice(0, limit);
73+
74+
for (const change of displayChanges) {
75+
const icon = change.type === 'improvement' ? '🟢' : '🔴';
76+
const sign = change.gasChange > 0 ? '+' : '';
77+
const diff = `${icon} ${sign}${formatGasValue(change.gasChange)}`;
78+
// Since we don't have old/new values in the new format, show N/A
79+
const before = change.oldGas ? formatGasValue(change.oldGas) : 'N/A';
80+
const after = change.newGas ? formatGasValue(change.newGas) : 'N/A';
81+
82+
table += `| ${change.contract} | ${change.function}() | ${before} | ${after} | ${diff} |\n`;
83+
}
84+
85+
if (changes.length > limit) {
86+
table += `\n*Showing top ${limit} changes out of ${changes.length} total.*`;
87+
}
88+
89+
return table;
90+
}
91+
92+
/**
93+
* Generate summary statistics
94+
*/
95+
function generateSummary(changes) {
96+
const improvements = changes.filter(c => c.type === 'improvement');
97+
const regressions = changes.filter(c => c.type === 'regression');
98+
99+
const totalImprovement = improvements.reduce((sum, c) => sum + Math.abs(c.gasChange), 0);
100+
const totalRegression = regressions.reduce((sum, c) => sum + Math.abs(c.gasChange), 0);
101+
const netChange = totalRegression - totalImprovement;
102+
103+
let netIcon = '⚪';
104+
let netText = 'No change';
105+
106+
if (netChange > 0) {
107+
netIcon = '🔴';
108+
netText = `+${formatGasValue(netChange)} gas`;
109+
} else if (netChange < 0) {
110+
netIcon = '🟢';
111+
netText = `${formatGasValue(netChange)} gas`;
112+
}
113+
114+
return {
115+
improvements: improvements.length,
116+
regressions: regressions.length,
117+
totalImprovement,
118+
totalRegression,
119+
netChange,
120+
netIcon,
121+
netText
122+
};
123+
}
124+
125+
/**
126+
* Generate timestamp footer for reports
127+
*/
128+
function generateFooter(shortSha, fullCommitSha) {
129+
const timestamp = new Date().toUTCString();
130+
const repo = process.env.GITHUB_REPOSITORY || 'owner/repo';
131+
const commitLink = `[\`${shortSha || 'unknown'}\`](https://github.com/${repo}/commit/${fullCommitSha})`;
132+
return `*Last updated: ${timestamp}* for commit ${commitLink}`;
133+
}
134+
135+
/**
136+
* Generate "no changes" report
137+
*/
138+
function generateNoChangesReport(baseBranch, headBranch, shortSha, fullCommitSha) {
139+
return `## Gas Report
140+
141+
No gas usage changes detected between \`${baseBranch}\` and \`${headBranch}\`.
142+
143+
All functions maintain the same gas costs. ✅
144+
145+
${generateFooter(shortSha, fullCommitSha)}`;
146+
}
147+
148+
/**
149+
* Generate about section
150+
*/
151+
function generateAboutSection() {
152+
return `<details>
153+
<summary>ℹ️ About this report</summary>
154+
155+
This report compares gas usage between the base branch and this PR using \`forge snapshot\`.
156+
- 🟢 indicates a gas improvement (reduction)
157+
- 🔴 indicates a gas regression (increase)
158+
- Functions not shown have unchanged gas costs
159+
160+
To run this locally:
161+
\`\`\`bash
162+
# Generate snapshot for current branch
163+
forge snapshot
164+
165+
# Compare with another branch
166+
git checkout main
167+
forge snapshot --diff .gas-snapshot
168+
\`\`\`
169+
170+
</details>`;
171+
}
172+
173+
/**
174+
* Generate the full markdown report
175+
*/
176+
function generateFullReport(diffOutput, prInfo = {}) {
177+
const baseBranch = prInfo.baseBranch || 'base';
178+
const headBranch = prInfo.headBranch || 'head';
179+
const fullCommitSha = prInfo.commitSha || '';
180+
const shortSha = fullCommitSha ? fullCommitSha.substring(0, 7) : '';
181+
182+
// Handle case with no changes
183+
if (!diffOutput || diffOutput.trim().length === 0) {
184+
return generateNoChangesReport(baseBranch, headBranch, shortSha, fullCommitSha);
185+
}
186+
187+
// Parse and analyze changes
188+
const changes = parseGasDiff(diffOutput);
189+
190+
// If no changes were parsed but there was diff output, it might be an error or unrecognized format
191+
if (changes.length === 0) {
192+
return generateNoChangesReport(baseBranch, headBranch, shortSha, fullCommitSha);
193+
}
194+
195+
const summary = generateSummary(changes);
196+
const table = generateMarkdownTable(changes);
197+
198+
return `## Gas Report
199+
200+
Comparing gas usage between \`${baseBranch}\` and \`${headBranch}\`
201+
202+
### Summary
203+
- **Optimized:** ${summary.improvements} functions (🟢 -${formatGasValue(summary.totalImprovement)} gas)
204+
- **Increased:** ${summary.regressions} functions (🔴 +${formatGasValue(summary.totalRegression)} gas)
205+
- **Net Change:** ${summary.netIcon} ${summary.netText}
206+
207+
### Details
208+
209+
${table}
210+
211+
${changes.length > 20 ? `
212+
<details>
213+
<summary>View all ${changes.length} changes</summary>
214+
215+
${generateMarkdownTable(changes, changes.length)}
216+
217+
</details>
218+
` : ''}
219+
220+
${generateAboutSection()}
221+
222+
${generateFooter(shortSha, fullCommitSha)}`;
223+
}
224+
225+
/**
226+
* Main function for CLI usage
227+
* Reads gas-diff.txt and generates gas-report.md
228+
*/
229+
function generateReportFromFile() {
230+
// Read the diff output
231+
const diffPath = path.join(process.cwd(), 'gas-diff.txt');
232+
if (!fs.existsSync(diffPath)) {
233+
console.error('Error: gas-diff.txt not found');
234+
process.exit(1);
235+
}
236+
237+
const diffOutput = fs.readFileSync(diffPath, 'utf8');
238+
239+
// Get PR info from environment
240+
const prInfo = {
241+
baseBranch: process.env.BASE_BRANCH || 'main',
242+
headBranch: process.env.HEAD_BRANCH || 'feature',
243+
commitSha: process.env.HEAD_SHA
244+
};
245+
246+
// Generate the report
247+
const report = generateFullReport(diffOutput, prInfo);
248+
249+
// Save the report
250+
const reportPath = path.join(process.cwd(), 'gas-report.md');
251+
fs.writeFileSync(reportPath, report, 'utf8');
252+
}
253+
254+
// CLI handling
255+
if (require.main === module) {
256+
const command = process.argv[2];
257+
258+
if (command === 'generate') {
259+
generateReportFromFile();
260+
} else {
261+
console.error('Usage: node gas-report.js generate');
262+
process.exit(1);
263+
}
264+
}

0 commit comments

Comments
 (0)