diff --git a/.github/scripts/coverage-comment.js b/.github/scripts/coverage-comment.js new file mode 100644 index 00000000..c21ae7ff --- /dev/null +++ b/.github/scripts/coverage-comment.js @@ -0,0 +1,171 @@ +const fs = require('fs'); + +// Configuration thresholds +const THRESHOLDS = { + good: 80, + needsImprovement: 60, + poor: 40 +}; + +/** + * Parse lcov.info file and extract coverage metrics + * @param {string} content - The lcov.info file content + * @returns {object} Coverage metrics + */ +function parseLcovContent(content) { + const lines = content.split('\n'); + let totalLines = 0; + let coveredLines = 0; + let totalFunctions = 0; + let coveredFunctions = 0; + let totalBranches = 0; + let coveredBranches = 0; + + // LF:, LH:, FNF:, FNH:, BRF:, BRH: are on separate lines + // We need to track them separately and sum them up + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Lines Found and Lines Hit + if (line.startsWith('LF:')) { + totalLines += parseInt(line.substring(3)) || 0; + } + if (line.startsWith('LH:')) { + coveredLines += parseInt(line.substring(3)) || 0; + } + + // Functions Found and Functions Hit + if (line.startsWith('FNF:')) { + totalFunctions += parseInt(line.substring(4)) || 0; + } + if (line.startsWith('FNH:')) { + coveredFunctions += parseInt(line.substring(4)) || 0; + } + + // Branches Found and Branches Hit + if (line.startsWith('BRF:')) { + totalBranches += parseInt(line.substring(4)) || 0; + } + if (line.startsWith('BRH:')) { + coveredBranches += parseInt(line.substring(4)) || 0; + } + } + + return { + totalLines, + coveredLines, + totalFunctions, + coveredFunctions, + totalBranches, + coveredBranches + }; +} + +/** + * Calculate coverage percentage + * @param {number} covered - Number of covered items + * @param {number} total - Total number of items + * @returns {number} Coverage percentage + */ +function calculateCoverage(covered, total) { + return total > 0 ? Math.round((covered / total) * 100) : 0; +} + +/** + * Get badge color based on coverage percentage + * @param {number} coverage - Coverage percentage + * @returns {string} Badge color + */ +function getBadgeColor(coverage) { + if (coverage >= THRESHOLDS.good) return 'brightgreen'; + if (coverage >= THRESHOLDS.needsImprovement) return 'yellow'; + if (coverage >= THRESHOLDS.poor) return 'orange'; + return 'red'; +} + +/** + * Generate coverage report comment body + * @param {object} metrics - Coverage metrics + * @returns {string} Markdown formatted comment body + */ +function generateCoverageReport(metrics) { + const lineCoverage = calculateCoverage(metrics.coveredLines, metrics.totalLines); + const functionCoverage = calculateCoverage(metrics.coveredFunctions, metrics.totalFunctions); + const branchCoverage = calculateCoverage(metrics.coveredBranches, metrics.totalBranches); + + const badgeColor = getBadgeColor(lineCoverage); + const badge = `![Coverage](https://img.shields.io/badge/coverage-${lineCoverage}%25-${badgeColor})`; + + return `## Coverage Report\n` + + `${badge}\n\n` + + `| Metric | Coverage | Details |\n` + + `|--------|----------|----------|\n` + + `| **Lines** | ${lineCoverage}% | ${metrics.coveredLines}/${metrics.totalLines} lines |\n` + + `| **Functions** | ${functionCoverage}% | ${metrics.coveredFunctions}/${metrics.totalFunctions} functions |\n` + + `| **Branches** | ${branchCoverage}% | ${metrics.coveredBranches}/${metrics.totalBranches} branches |\n\n`; +} + +/** + * Main function to post coverage comment + * @param {object} github - GitHub API object + * @param {object} context - GitHub Actions context + */ +async function postCoverageComment(github, context) { + const file = 'lcov.info'; + + if (!fs.existsSync(file)) { + console.log('Coverage file not found.'); + return; + } + + const content = fs.readFileSync(file, 'utf8'); + const metrics = parseLcovContent(content); + + console.log('Coverage Metrics:'); + console.log('- Lines:', metrics.coveredLines, '/', metrics.totalLines); + console.log('- Functions:', metrics.coveredFunctions, '/', metrics.totalFunctions); + console.log('- Branches:', metrics.coveredBranches, '/', metrics.totalBranches); + + const body = generateCoverageReport(metrics); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + console.log('Coverage comment posted successfully!'); +} + +/** + * Generate coverage report and save to file (for workflow artifacts) + */ +function generateCoverageFile() { + const file = 'lcov.info'; + + if (!fs.existsSync(file)) { + console.log('Coverage file not found.'); + return; + } + + const content = fs.readFileSync(file, 'utf8'); + const metrics = parseLcovContent(content); + + console.log('Coverage Metrics:'); + console.log('- Lines:', metrics.coveredLines, '/', metrics.totalLines); + console.log('- Functions:', metrics.coveredFunctions, '/', metrics.totalFunctions); + console.log('- Branches:', metrics.coveredBranches, '/', metrics.totalBranches); + + const body = generateCoverageReport(metrics); + fs.writeFileSync('coverage-report.md', body); + console.log('Coverage report saved to coverage-report.md'); +} + +// If run directly (not as module), generate the file +if (require.main === module) { + generateCoverageFile(); +} + +module.exports = { postCoverageComment, generateCoverageFile }; + diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml new file mode 100644 index 00000000..fdadc92f --- /dev/null +++ b/.github/workflows/coverage-comment.yml @@ -0,0 +1,120 @@ +name: Post Coverage Comment + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + actions: read + pull-requests: write + issues: write + contents: read + +jobs: + comment: + name: Post Coverage Comment + runs-on: ubuntu-latest + # Only run if the workflow run was for a pull request + if: github.event.workflow_run.event == 'pull_request' + steps: + - name: Check workflow run status + run: | + echo "Workflow run conclusion: ${{ github.event.workflow_run.conclusion }}" + echo "Workflow run event: ${{ github.event.workflow_run.event }}" + echo "Workflow run ID: ${{ github.event.workflow_run.id }}" + echo "Head branch: ${{ github.event.workflow_run.head_branch }}" + + - name: Download and post coverage comment + if: github.event.workflow_run.conclusion == 'success' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + const { execSync } = require('child_process'); + + // Download artifact + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + const coverageArtifact = artifacts.data.artifacts.find( + artifact => artifact.name === 'coverage-data' + ); + + if (!coverageArtifact) { + console.log('No coverage artifact found'); + return; + } + + console.log('Found coverage artifact, downloading...'); + + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: coverageArtifact.id, + archive_format: 'zip', + }); + + // Save and extract the artifact using execSync + const artifactPath = path.join(process.env.GITHUB_WORKSPACE, 'coverage-data.zip'); + fs.writeFileSync(artifactPath, Buffer.from(download.data)); + + console.log('Artifact downloaded, extracting...'); + + // Unzip the artifact + execSync(`unzip -o ${artifactPath} -d ${process.env.GITHUB_WORKSPACE}`); + + // Extract PR number + const prDataPath = path.join(process.env.GITHUB_WORKSPACE, 'coverage-data.txt'); + const prData = fs.readFileSync(prDataPath, 'utf8'); + const prMatch = prData.match(/PR_NUMBER=(\d+)/); + + if (!prMatch) { + console.log('Could not find PR number in coverage-data.txt'); + return; + } + + const prNumber = parseInt(prMatch[1]); + console.log(`Processing coverage for PR #${prNumber}`); + + // Read coverage report + const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'coverage-report.md'); + const body = fs.readFileSync(reportPath, 'utf8'); + + // Check if a coverage comment already exists + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('## Coverage Report') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + console.log('Coverage comment updated successfully!'); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + console.log('Coverage comment posted successfully!'); + } \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..99b3aa63 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run coverage + run: | + forge coverage --report summary --report lcov + ls -la lcov.info || echo "lcov.info not found" + + - name: Generate coverage report + if: github.event_name == 'pull_request' + run: | + node .github/scripts/coverage-comment.js + echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> coverage-data.txt + echo "Generated coverage report for PR #${{ github.event.pull_request.number }}" + + - name: Upload coverage data + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: coverage-data + path: | + coverage-report.md + coverage-data.txt + retention-days: 1 diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml new file mode 100644 index 00000000..af6aedfd --- /dev/null +++ b/.github/workflows/slither.yml @@ -0,0 +1,36 @@ +name: CI + + +on: + push: + branches: [main] + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Build contracts + run: forge build + + - name: Install Slither + run: | + pip install slither-analyzer + + - name: Run Slither analysis + run: | + slither . --exclude-dependencies \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4481ec6a..9dce09e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,10 @@ name: CI on: push: + branches: [main] pull_request: + paths-ignore: + - '**.md' workflow_dispatch: env: @@ -10,7 +13,7 @@ env: jobs: check: - name: Foundry project + name: Foundry Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/foundry.toml b/foundry.toml index da075af4..4e610efe 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,4 +5,14 @@ libs = ["lib"] optimizer = true optimizer_runs = 20_000 +[profile.ci] +src = "src" +out = "out" +libs = ["lib"] +optimizer = true +optimizer_runs = 20_000 +# Coverage settings +fuzz = { runs = 1000 } +invariant = { runs = 1000 } + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options