Files
backstage/scripts/generate-changeset-feedback.js
T
Patrik Oldsberg fce80bba15 scripts/generate-changeset-feedback: fix incorrect prop
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
2022-12-06 14:53:25 +01:00

315 lines
8.3 KiB
JavaScript

/*
* Copyright 2022 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');
const { execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const {
basename,
resolve: resolvePath,
relative: relativePath,
} = require('path');
const execFile = promisify(execFileCb);
// Tells whether a path relative to the package directory has an effect
// on the published package
function isPublishedPath(path) {
if (path.startsWith('dev/')) {
return false;
}
if (path.includes('__mocks__')) {
return false;
}
if (path.includes('__fixtures__')) {
return false;
}
// Don't count manual modifications to the changelog
if (path === 'CHANGELOG.md') {
return false;
}
// API report changes by themselves don't count
if (path === 'api-report.md' || path === 'cli-report.md') {
return false;
}
// Lint changes don't count
if (path === '.eslintrc.js') {
return false;
}
const name = basename(path);
if (name.startsWith('setupTests.')) {
return false;
}
if (name.includes('.test.')) {
return false;
}
if (name.includes('.stories.')) {
return false;
}
return true;
}
async function listChangedFiles(ref) {
if (!ref) {
throw new Error('ref is required');
}
const { stdout } = await execFile('git', ['diff', '--name-only', ref]);
return stdout.trim().split(/\r?\n/);
}
async function listPackages() {
const rootPkg = require(resolvePath(process.cwd(), './package.json'));
if (!rootPkg?.workspaces?.packages) {
throw new Error('No workspaces found in root package.json');
}
const pkgs = [];
// Naive workspace lookup implementation, we can't shell out to yarn here as the implementation embedded in the repo
for (const pkgPath of rootPkg?.workspaces?.packages) {
const readDirRecursive = (dir, parts) => {
const [nextPart] = parts;
// We've reached the end of the path pattern, check if package.json exists
if (!nextPart) {
try {
const pkg = require(resolvePath(dir, 'package.json'));
pkgs.push({
path: relativePath(process.cwd(), dir),
name: pkg.name,
});
} catch {
process.stderr.write(`Failed to read package.json in ${dir}\n`);
}
return;
}
for (const filePath of fs.readdirSync(dir)) {
if (fs.statSync(resolvePath(dir, filePath)).isDirectory()) {
if (filePath === nextPart || nextPart === '*') {
readDirRecursive(resolvePath(dir, filePath), parts.slice(1));
}
}
}
};
// Split the workspace paths by / and check each directory level recursively
readDirRecursive(process.cwd(), pkgPath.split('/'));
}
return pkgs;
}
async function loadChangesets(filePaths) {
const changesets = [];
for (const filePath of filePaths) {
if (!filePath.startsWith('.changeset/') || !filePath.endsWith('.md')) {
continue;
}
try {
const content = await fs.promises.readFile(filePath, 'utf8');
let lines = content.split(/\r?\n/);
lines = lines.slice(lines.findIndex(line => line === '---') + 1);
lines = lines.slice(
0,
lines.findIndex(line => line === '---'),
);
const bumps = new Map();
bumps.toJSON = () => Object.fromEntries(bumps);
for (const line of lines) {
const match = line.match(/^'(.*)': (patch|minor|major)$/);
if (!match) {
throw new Error(`Invalid changeset line: ${line}`);
}
bumps.set(match[1], match[2]);
}
changesets.push({
filePath,
bumps,
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
return changesets;
}
async function listChangedPackages(changedFiles, packages) {
const changedPackageMap = new Map();
for (const filePath of changedFiles) {
for (const pkg of packages) {
if (filePath.startsWith(`${pkg.path}/`)) {
const pkgPath = relativePath(pkg.path, filePath);
if (!isPublishedPath(pkgPath)) {
break;
}
const entry = changedPackageMap.get(pkg.name);
if (entry) {
entry.files.push(pkgPath);
} else {
const pkgJson = require(resolvePath(pkg.path, 'package.json'));
changedPackageMap.set(pkg.name, {
...pkg,
version: pkgJson.version,
isStable: !pkgJson.version.startsWith('0.'),
isPrivate: Boolean(pkgJson.private),
files: [pkgPath],
});
}
break;
}
}
}
return Array.from(changedPackageMap.values());
}
function formatSection(prefix = [], generator, suffix = ['']) {
const lines = Array.from(generator());
if (lines.length === 0) {
return '';
}
return [...[prefix].flat(), ...lines, ...[suffix].flat(), '', ''].join('\n');
}
function formatSummary(changedPackages, changesets) {
const changedNames = new Set(changedPackages.map(pkg => pkg.name));
let output = '';
output += formatSection(
`## Missing Changesets
The following package(s) are changed by this PR but do not have a changeset:
`,
function* section() {
for (const pkg of changedPackages) {
if (changesets.some(c => c.bumps.get(pkg.name))) {
continue;
}
if (pkg.isPrivate) {
continue;
}
yield `- **${pkg.name}**`;
}
},
`
See [CONTRIBUTING.md](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets) for more information about how to add changesets.
`,
);
output += formatSection(
`## Unexpected Changesets
The following changeset(s) reference packages that have not been changed in this PR:
`,
function* section() {
for (const c of changesets) {
const missing = Array.from(c.bumps.keys()).filter(
b => !changedNames.has(b),
);
if (missing.length > 0) {
yield `- **${c.filePath}**: ${missing.join(', ')}`;
}
}
},
`
Note that only changes that affect the published package require changesets, for example changes to tests and storybook stories do not require changesets.
`,
);
output += formatSection(
`## Unnecessary Changesets
The following package(s) are private and do not need a changeset:
`,
function* section() {
for (const pkg of changedPackages) {
if (changesets.some(c => c.bumps.get(pkg.name)) && pkg.isPrivate) {
yield `- **${pkg.name}**`;
}
}
},
);
output += formatSection(
`## Changed Packages
| Package Name | Package Path | Changeset Bump | Current Version |
|:-------------|:-------------|:--------------:|:----------------|`,
function* section() {
const bumpMap = {
undefined: -1,
patch: 0,
minor: 1,
major: 2,
};
for (const pkg of changedPackages) {
const maxBump =
changesets
.map(c => c.bumps.get(pkg.name))
.reduce(
(max, bump) => (bumpMap[bump] > bumpMap[max] ? bump : max),
undefined,
) ?? 'none';
yield `| ${pkg.name} | ${pkg.path} | **${maxBump}** | \`v${pkg.version}\` |`;
}
},
);
return output;
}
async function main() {
const [diffRef = 'origin/master'] = process.argv.slice(2);
const changedFiles = await listChangedFiles(diffRef);
const packages = await listPackages();
const changesets = await loadChangesets(changedFiles);
const changedPackages = await listChangedPackages(changedFiles, packages);
process.stderr.write(
JSON.stringify(
{
changesets,
changedPackages,
},
null,
2,
),
);
process.stderr.write('\n');
const summary = formatSummary(changedPackages, changesets);
process.stdout.write(summary);
}
main().catch(error => {
console.error(error.stack);
process.exit(1);
});