Compare commits

..

5 Commits

Author SHA1 Message Date
Jesse David Peterson c49a78e925 fix(script): use Jest binary path instead of assuming install 2026-01-14 21:35:01 -04:00
Jesse David Peterson 34347aa1f0 chore(CODEOWNERS): make DataViz owners of test coverage comparison code 2026-01-14 21:22:45 -04:00
Jesse David Peterson 468bf2e611 feat(ci-workflow): compare test coverage for opted in codeowners
.
2026-01-14 21:22:44 -04:00
Jesse David Peterson bcca6d0f81 feat(script): compare PR + main code coverage by codeowner 2026-01-14 20:54:32 -04:00
Jesse David Peterson 87b0212844 feat(test): calculate coverage summaries in codeowner jest config 2026-01-14 20:54:32 -04:00
6 changed files with 376 additions and 24 deletions
+2
View File
@@ -1058,6 +1058,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/scripts/clean-git-or-error.sh @grafana/grafana-as-code
/scripts/grafana-server/ @grafana/grafana-frontend-platform
/scripts/check-frontend-dev.sh @grafana/grafana-frontend-platform
/scripts/compare-coverage-by-codeowner.js @grafana/dataviz-squad
/scripts/helpers/ @grafana/grafana-developer-enablement-squad
/scripts/import_many_dashboards.sh @torkelo
/scripts/mixin-check.sh @bergquist
@@ -1297,6 +1298,7 @@ embed.go @grafana/grafana-as-code
/.github/workflows/swagger-gen.yml @grafana/grafana-backend-group
/.github/workflows/pr-frontend-unit-tests.yml @grafana/grafana-frontend-platform
/.github/workflows/frontend-lint.yml @grafana/grafana-frontend-platform
/.github/workflows/pr-coverage-by-team.yml @grafana/dataviz-squad
/.github/workflows/analytics-events-report.yml @grafana/grafana-frontend-platform
/.github/workflows/pr-e2e-tests.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/skye-add-to-project.yml @grafana/grafana-frontend-platform
+181
View File
@@ -0,0 +1,181 @@
name: PR Coverage by Team
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- '.github/CODEOWNERS'
permissions: {}
jobs:
setup:
name: Setup codeowners manifest
runs-on: ubuntu-x64-small
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Install dependencies
run: yarn install --immutable
env:
PUPPETEER_SKIP_DOWNLOAD: true
CYPRESS_INSTALL_BINARY: 0
- name: Generate codeowners manifest
run: yarn codeowners-manifest
- name: Upload codeowners manifest
uses: actions/upload-artifact@v5
with:
name: codeowners-manifest
path: codeowners-manifest/
retention-days: 1
coverage:
name: Coverage ${{ matrix.branch }} - ${{ matrix.team }}
needs: [setup]
runs-on: ubuntu-x64-large
permissions:
contents: read
strategy:
fail-fast: false
matrix:
# Opted-in teams for coverage tracking
team: &teams
- '@grafana/dataviz-squad'
branch: [pr, main]
steps:
- uses: actions/checkout@v5
with:
ref: ${{ matrix.branch == 'main' && 'main' || github.ref }}
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Download codeowners manifest
uses: actions/download-artifact@v6
with:
name: codeowners-manifest
path: codeowners-manifest/
- name: Install dependencies
run: yarn install --immutable
env:
PUPPETEER_SKIP_DOWNLOAD: true
CYPRESS_INSTALL_BINARY: 0
- name: Generate team slug
id: team-slug
run: |
node -e "
const { createOwnerFilenameSlug } = require('./scripts/codeowners-manifest/utils.js');
console.log('team-slug=' + createOwnerFilenameSlug('${{ matrix.team }}'));
" >> "$GITHUB_OUTPUT"
- name: Run coverage for ${{ matrix.team }}
run: node scripts/test-coverage-by-codeowner.js "${{ matrix.team }}"
env:
CODEOWNER_NAME: ${{ matrix.team }}
SHOULD_OPEN_COVERAGE_REPORT: 'false'
CI: 'true'
- name: Upload coverage summary
uses: actions/upload-artifact@v5
with:
name: coverage-${{ matrix.branch }}-${{ steps.team-slug.outputs.team-slug }}
path: coverage-summary.json
retention-days: 1
compare-and-report:
name: Compare & Report - ${{ matrix.team }}
needs: [coverage]
runs-on: ubuntu-x64-small
permissions:
contents: read
pull-requests: write
id-token: write
strategy:
fail-fast: false
matrix:
team: *teams
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Generate team slug
id: team-slug
run: |
node -e "
const { createOwnerFilenameSlug } = require('./scripts/codeowners-manifest/utils.js');
console.log('team-slug=' + createOwnerFilenameSlug('${{ matrix.team }}'));
" >> "$GITHUB_OUTPUT"
- name: Download PR coverage
uses: actions/download-artifact@v6
with:
name: coverage-pr-${{ steps.team-slug.outputs.team-slug }}
path: ./coverage-pr
- name: Download Main coverage
uses: actions/download-artifact@v6
with:
name: coverage-main-${{ steps.team-slug.outputs.team-slug }}
path: ./coverage-main
- name: Install dependencies for comparison script
run: yarn install --immutable
env:
PUPPETEER_SKIP_DOWNLOAD: true
CYPRESS_INSTALL_BINARY: 0
- name: Compare coverage
id: compare
run: |
node scripts/compare-coverage-by-codeowner.js
{
echo "markdown<<EOF"
cat ./coverage-comparison.md
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Get Vault secrets
id: get-secrets
if: github.event.pull_request.head.repo.fork == false
uses: grafana/shared-workflows/actions/get-vault-secrets@main
with:
repo_secrets: |
GITHUB_APP_ID=grafana_pr_automation_app:app_id
GITHUB_APP_PRIVATE_KEY=grafana_pr_automation_app:app_pem
- name: Generate GitHub App token
id: generate_token
if: github.event.pull_request.head.repo.fork == false
uses: actions/create-github-app-token@v1
with:
app-id: ${{ env.GITHUB_APP_ID }}
private-key: ${{ env.GITHUB_APP_PRIVATE_KEY }}
- name: Post PR comment
if: github.event.pull_request.head.repo.fork == false
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage-report-${{ matrix.team }}
message: ${{ steps.compare.outputs.markdown }}
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
+26 -21
View File
@@ -3,8 +3,10 @@ const open = require('open').default;
const path = require('path');
const baseConfig = require('./jest.config.js');
const { createOwnerDirectory, createOwnerFilenameSlug } = require('./scripts/codeowners-manifest/utils.js');
const CODEOWNERS_MANIFEST_FILENAMES_BY_TEAM_PATH = 'codeowners-manifest/filenames-by-team.json';
const COVERAGE_SUMMARY_OUTPUT_PATH = './coverage-summary.json';
const codeownerName = process.env.CODEOWNER_NAME;
if (!codeownerName) {
@@ -100,6 +102,8 @@ module.exports = {
openCoverageReport(reportURL);
}
writeCoverageSummaryArtifact(coverageResults);
// TODO: Emit coverage metrics https://github.com/grafana/grafana/issues/111208
},
},
@@ -111,30 +115,31 @@ module.exports = {
testMatch: testFiles.map((file) => `<rootDir>/${file}`),
};
/**
* Create a filesystem-safe directory structure for different owner types
* @param {string} owner - CODEOWNERS owner (username, team, or email)
* @returns {string} Directory path relative to coverage/by-team/
*/
function createOwnerDirectory(owner) {
if (owner.includes('@') && owner.includes('/')) {
// Example: @grafana/dataviz-squad
const [org, team] = owner.substring(1).split('/');
return `teams/${org}/${team}`;
} else if (owner.startsWith('@')) {
// Example: @jesdavpet
return `users/${owner.substring(1)}`;
} else {
// Example: user@domain.tld
const [user, domain] = owner.split('@');
return `emails/${user}-at-${domain}`;
function writeCoverageSummaryArtifact(coverageResults) {
if (!coverageResults || !coverageResults.summary) {
return;
}
const summary = {
team: codeownerName,
commit: process.env.GITHUB_SHA || 'unknown',
timestamp: new Date().toISOString(),
summary: {
lines: { pct: coverageResults.summary.lines.pct },
statements: { pct: coverageResults.summary.statements.pct },
functions: { pct: coverageResults.summary.functions.pct },
branches: { pct: coverageResults.summary.branches.pct },
},
};
try {
fs.writeFileSync(COVERAGE_SUMMARY_OUTPUT_PATH, JSON.stringify(summary, null, 2));
console.log(`📊 Coverage summary written to ${COVERAGE_SUMMARY_OUTPUT_PATH}`);
} catch (err) {
console.error(`Failed to write coverage summary: ${err}`);
}
}
/**
* Open the given file URL in the default browser safely, without shell injection risk.
* @param {string} reportURL
*/
async function openCoverageReport(reportURL) {
try {
await open(reportURL);
+45 -1
View File
@@ -4,9 +4,29 @@ const { CODEOWNERS_JSON_PATH: CODEOWNERS_MANIFEST_CODEOWNERS_PATH } = require('.
let _codeownersCache = null;
/**
* Creates a filesystem-safe slug for different CODEOWNERS owner types
* @param {string} owner - CODEOWNERS owner (username, team, or email)
* @param {string} delimiter - Delimiter to use between parts (default: '/')
* @returns {string} Slugified owner string with type prefix to avoid collisions
*/
function createOwnerSlug(owner, delimiter = '/') {
if (owner.includes('@') && owner.includes('/')) {
const [org, team] = owner.substring(1).split('/');
return ['team', org, team].join(delimiter);
} else if (owner.startsWith('@')) {
return ['user', owner.substring(1)].join(delimiter);
} else {
const [user, domain] = owner.split('@');
const sanitizedUser = user.replace(/[+.]/g, delimiter);
const sanitizedDomain = domain.replace(/\./g, delimiter);
return ['email', `${sanitizedUser}-at-${sanitizedDomain}`].join(delimiter);
}
}
module.exports = {
/**
* import the contents of the codeowners manifest JSON file, with caching
* Imports the contents of the codeowners manifest JSON file, with caching
* @param {boolean} clearCache - if true, clear the cached data and reload the codeowners manifest
* @returns {Promise<Array<string>>} - list of codeowners which own at least one file in the project
*/
@@ -31,4 +51,28 @@ module.exports = {
return _codeownersCache;
},
/**
* Create a filesystem-safe directory structure for different owner types
* @param {string} owner - CODEOWNERS owner (username, team, or email)
* @returns {string} Directory path relative to coverage/by-team/
*
* @example
* createOwnerDirectory('@grafana/dataviz-squad') => 'teams/grafana/dataviz-squad'
*/
createOwnerDirectory(owner) {
return createOwnerSlug(owner, '/');
},
/**
* Create a filename-safe slug for artifacts and filenames
* @param {string} owner - CODEOWNERS owner (username, team, or email)
* @returns {string} Filename-safe slug
*
* @example
* createOwnerFilenameSlug('@grafana/dataviz-squad') => 'teams-grafana-dataviz-squad'
*/
createOwnerFilenameSlug(owner) {
return createOwnerSlug(owner, '-');
},
};
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const COVERAGE_PR_PATH = './coverage-pr/coverage-summary.json';
const COVERAGE_MAIN_PATH = './coverage-main/coverage-summary.json';
const COMPARISON_OUTPUT_PATH = './coverage-comparison.md';
function readCoverageFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (err) {
console.error(`Error reading coverage file ${filePath}: ${err.message}`);
process.exit(1);
}
}
function formatPercentage(value) {
return `${value.toFixed(1)}%`;
}
function getStatusIcon(prValue, mainValue) {
if (prValue >= mainValue) {
return '✅ Pass';
}
return '❌ Fail';
}
function getOverallStatus(prSummary, mainSummary) {
const metrics = ['lines', 'statements', 'functions', 'branches'];
const allPass = metrics.every((metric) => prSummary[metric].pct >= mainSummary[metric].pct);
return allPass;
}
function generateMarkdown(prCoverage, mainCoverage) {
const teamName = prCoverage.team;
const prSum = prCoverage.summary;
const mainSum = mainCoverage.summary;
const overallPass = getOverallStatus(prSum, mainSum);
const rows = [
{
metric: 'Lines',
main: mainSum.lines.pct,
pr: prSum.lines.pct,
},
{
metric: 'Statements',
main: mainSum.statements.pct,
pr: prSum.statements.pct,
},
{
metric: 'Functions',
main: mainSum.functions.pct,
pr: prSum.functions.pct,
},
{
metric: 'Branches',
main: mainSum.branches.pct,
pr: prSum.branches.pct,
},
];
const tableRows = rows
.map((row) => {
const status = getStatusIcon(row.pr, row.main);
return `| ${row.metric} | ${formatPercentage(row.main)} | ${formatPercentage(row.pr)} | ${status} |`;
})
.join('\n');
const overallStatus = overallPass ? '✅ Pass' : '❌ Fail';
const overallMessage = overallPass ? 'Coverage maintained or improved' : 'Coverage decreased in one or more metrics';
return `## Test Coverage Report - ${teamName}
| Metric | Main Branch | PR Branch | Status |
|--------|-------------|-----------|--------|
${tableRows}
**Overall: ${overallStatus}** - ${overallMessage}
<details>
<summary>Coverage Details</summary>
- **PR Branch**: \`${prCoverage.commit.substring(0, 7)}\` (${prCoverage.timestamp})
- **Main Branch**: \`${mainCoverage.commit.substring(0, 7)}\` (${mainCoverage.timestamp})
</details>
`;
}
function main() {
const prCoverage = readCoverageFile(COVERAGE_PR_PATH);
const mainCoverage = readCoverageFile(COVERAGE_MAIN_PATH);
if (!prCoverage.summary || !mainCoverage.summary) {
console.error('Error: Coverage summary data is missing or invalid');
process.exit(1);
}
const markdown = generateMarkdown(prCoverage, mainCoverage);
try {
fs.writeFileSync(COMPARISON_OUTPUT_PATH, markdown, 'utf8');
console.log(`✅ Coverage comparison written to ${COMPARISON_OUTPUT_PATH}`);
} catch (err) {
console.error(`Error writing output file: ${err.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { generateMarkdown, getOverallStatus };
+3 -2
View File
@@ -2,6 +2,7 @@
const { AutoComplete } = require('enquirer');
const cp = require('node:child_process');
const path = require('node:path');
const { hideBin } = require('yargs/helpers');
const yargs = require('yargs/yargs');
@@ -35,9 +36,9 @@ async function runTestCoverageByCodeowner(codeownerName, noOpen = process.env.CI
process.env.SHOULD_OPEN_COVERAGE_REPORT = String(!noOpen);
return new Promise((resolve, reject) => {
const child = cp.spawn('jest', [`--config=${JEST_CONFIG_PATH}`], {
const jestBin = path.join(__dirname, '..', 'node_modules', '.bin', 'jest');
const child = cp.spawn(jestBin, [`--config=${JEST_CONFIG_PATH}`], {
stdio: 'inherit',
shell: true,
});
child.on('error', (error) => {