|
| 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