diff --git a/.gitignore b/.gitignore index 7149dbabc1..c0ef0d2001 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,7 @@ e2e-test-report/ # VS Code backing up svg files *svg.bkp -*svg.dtmp \ No newline at end of file +*svg.dtmp + +# Scripts +plugins-report.csv \ No newline at end of file diff --git a/package.json b/package.json index bad49ab55f..b50e087aa7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:api-reports": "yarn build:api-reports:only --tsc", "build:api-reports:only": "backstage-repo-tools api-reports --allow-warnings 'packages/core-components,plugins/+(catalog|catalog-import|git-release-manager|jenkins|kubernetes)' -o ae-wrong-input-file-type --validate-release-tags", "build:api-docs": "LANG=en_EN yarn build:api-reports --docs", + "build:plugins-report": "node ./scripts/build-plugins-report", "tsc": "tsc", "tsc:full": "backstage-cli repo clean && tsc --skipLibCheck false --incremental false", "clean": "backstage-cli repo clean", diff --git a/scripts/build-plugins-report.js b/scripts/build-plugins-report.js new file mode 100755 index 0000000000..2caaba4530 --- /dev/null +++ b/scripts/build-plugins-report.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs-extra'); +const path = require('path'); +const { execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); + +const execFile = promisify(execFileCb); + +const rootDirectory = path.resolve(__dirname, '..'); +const pluginsDirectory = path.resolve(rootDirectory, 'plugins'); + +async function run(command, ...args) { + const { stdout, stderr } = await execFile(command, args, { + cwd: rootDirectory, + }); + + if (stderr) { + console.error(stderr); + } + + return stdout.trim(); +} + +function findLatestValidCommit(commits, directoryPath) { + return commits.find(commit => { + const { author, message, files } = commit; + + // exclude merge commits + if (message.startsWith('Merge pull request #')) { + return false; + } + + // exclude commits authored by a bot + if (author.includes('[bot]')) { + return false; + } + + // exclude core maintainers' commits + if ( + [ + 'ben@blam.sh', + 'freben@gmail.com', + 'poldsberg@gmail.com', + 'johan.haals@gmail.com', + ].some(email => author.includes(email)) + ) { + return false; + } + + // ignore multiple plugins changes + if ( + files.some(file => { + const fileFullPath = path.resolve(rootDirectory, file); + if (!fileFullPath.startsWith(pluginsDirectory)) { + return false; + } + const pluginBasePath = directoryPath.replace( + /-(backend|common|react|node).*$/, + '', + ); + return !fileFullPath.startsWith(pluginBasePath); + }) + ) { + return false; + } + + return true; + }); +} + +async function getPluginDirectory(directoryName) { + const directoryPath = path.resolve(pluginsDirectory, directoryName); + const packageJson = await fs.readJson( + path.resolve(directoryPath, 'package.json'), + ); + return { directoryName, directoryPath, packageJson }; +} + +function parseCommitsLog(log) { + const lines = log.split('\n'); + return lines.reduce((commits, line) => { + if (!line) return commits; + if (line.includes(';')) { + const [author, message, date] = line.split(';'); + return [...commits, { author, message, date, files: [] }]; + } + const { files, ...commit } = commits.pop(); + return [...commits, { ...commit, files: [...files, line] }]; + }, []); +} + +async function readDirectoryCommits(directoryName) { + const maxCount = 100; + const directoryPath = path.resolve(pluginsDirectory, directoryName); + + const logOutput = await run( + 'git', + 'log', + 'origin/master', + '--name-only', + `--max-count=${maxCount}`, + '--pretty=format:%an <%ae>;%s;%as', + '--', + // ignore changes on README and package.json files + path.posix.resolve(directoryPath, 'src'), + `:(exclude)${path.posix.resolve(directoryPath, 'src', '**', '*.test.*')}`, + ); + + return parseCommitsLog(logOutput); +} + +async function getLatestDirectoryCommit({ directoryName, directoryPath }) { + console.log(`🔎 Reading data for ${directoryName}`); + + const commits = await readDirectoryCommits(directoryName); + + const commit = findLatestValidCommit(commits, directoryPath) ?? { + author: '-', + message: '-', + date: '-', + }; + + return { plugin: directoryName, ...commit }; +} + +function isValidDirectory({ packageJson }) { + const roles = [ + 'frontend-plugin', + 'frontend-plugin-module', + 'backend-plugin', + 'backend-plugin-module', + ]; + return roles.includes(packageJson?.backstage?.role ?? ''); +} + +async function main() { + const directoryNames = fs + .readdirSync(pluginsDirectory, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + const directories = await Promise.all(directoryNames.map(getPluginDirectory)); + + const commits = await Promise.all( + directories.filter(isValidDirectory).map(getLatestDirectoryCommit), + ); + + const fileName = 'plugins-report.csv'; + + const fileContent = [ + 'Plugin;Author;Message;Date', + ...commits.map(c => `${c.plugin};${c.author};${c.message};${c.date}`), + ]; + + console.log(`📊 Generating plugins report...`); + + fs.writeFile(fileName, fileContent.join('\n'), err => { + if (err) throw err; + }); + + console.log(`📄 Report generated at ${fileName}`); +} + +main(process.argv.slice(2)).catch(error => { + console.error(error.stack || error); + process.exit(1); +});