diff --git a/.github/scripts/coverage-comment.js b/.github/scripts/coverage-comment.js new file mode 100644 index 00000000..f6cb9afb --- /dev/null +++ b/.github/scripts/coverage-comment.js @@ -0,0 +1,190 @@ +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 + * @param {object} commitInfo - Optional commit information + * @returns {string} Markdown formatted comment body + */ +function generateCoverageReport(metrics, commitInfo = {}) { + 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})`; + + // Generate timestamp + const timestamp = new Date().toUTCString(); + + // Build commit link if info is available + let commitLink = ''; + if (commitInfo.sha && commitInfo.owner && commitInfo.repo) { + const shortSha = commitInfo.sha.substring(0, 7); + commitLink = ` for commit [\`${shortSha}\`](https://github.com/${commitInfo.owner}/${commitInfo.repo}/commit/${commitInfo.sha})`; + } + + 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` + + `*Last updated: ${timestamp}*${commitLink}\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); + + // Get commit info from environment variables + const commitInfo = { + sha: process.env.COMMIT_SHA, + owner: process.env.REPO_OWNER, + repo: process.env.REPO_NAME + }; + + const body = generateCoverageReport(metrics, commitInfo); + 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/scripts/post-coverage-comment.js b/.github/scripts/post-coverage-comment.js new file mode 100644 index 00000000..622bd1ca --- /dev/null +++ b/.github/scripts/post-coverage-comment.js @@ -0,0 +1,119 @@ +/** + * Post coverage comment workflow script + * Downloads coverage artifact from a workflow run and posts/updates PR comment + * + * This script is designed to run in a workflow_run triggered workflow + * with proper permissions to comment on PRs from forks. + */ + +module.exports = async ({ github, context }) => { + const fs = require('fs'); + const path = require('path'); + const { execSync } = require('child_process'); + + console.log('Starting coverage comment posting process...'); + + // Download artifact + console.log('Fetching artifacts from workflow run...'); + 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'); + console.log('Available artifacts:', artifacts.data.artifacts.map(a => a.name).join(', ')); + 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'); + + if (!fs.existsSync(prDataPath)) { + console.log('coverage-data.txt not found in artifact'); + console.log('Extracted files:', execSync(`ls -la ${process.env.GITHUB_WORKSPACE}`).toString()); + return; + } + + 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'); + console.log('File contents:', prData); + 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'); + + if (!fs.existsSync(reportPath)) { + console.log("coverage-report.md not found in artifact"); + return; + } + + const body = fs.readFileSync(reportPath, 'utf8'); + console.log('✓ Coverage report loaded'); + + // Check if a coverage comment already exists + console.log('Checking for existing coverage comments...'); + 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 + console.log(`Updating existing comment (ID: ${botComment.id})...`); + 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 + console.log('Creating new coverage 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!"); + } +}; + diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml new file mode 100644 index 00000000..2b48b71b --- /dev/null +++ b/.github/workflows/coverage-comment.yml @@ -0,0 +1,40 @@ +name: Comment Report on Coverage + +on: + workflow_run: + workflows: ["Coverage"] + 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: Checkout repository + if: github.event.workflow_run.conclusion == 'success' + uses: actions/checkout@v4 + + - 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 script = require('./.github/scripts/post-coverage-comment.js'); + await script({ github, context }); \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..37f6908f --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,52 @@ +name: Coverage + +on: + push: + branches: [main] + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + 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' + env: + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + node .github/scripts/coverage-comment.js + echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> coverage-data.txt + echo "COMMIT_SHA=${{ github.event.pull_request.head.sha }}" >> coverage-data.txt + echo "REPO_OWNER=${{ github.repository_owner }}" >> coverage-data.txt + echo "REPO_NAME=${{ github.event.repository.name }}" >> 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..6026b044 --- /dev/null +++ b/.github/workflows/slither.yml @@ -0,0 +1,35 @@ +name: Slither + + +on: + push: + branches: [main] + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + 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..489fb182 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,11 @@ -name: CI +name: Test on: push: + branches: [main] pull_request: + paths-ignore: + - '**.md' workflow_dispatch: env: @@ -10,7 +13,6 @@ env: jobs: check: - name: Foundry project 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