Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c49a78e925 | |||
| 34347aa1f0 | |||
| 468bf2e611 | |||
| bcca6d0f81 | |||
| 87b0212844 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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, '-');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user