diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 154c5e87a..c92f3d564 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -27,3 +27,11 @@ > - major: if the change breaks compatibility > > In case of conflict, the highest (lowest in previous list) `$version` wins. +> +> The `gen-deps-list` script can be used to generate this list of dependencies +> Run `scripts/gen-deps-list.js --help` for usage + + + + + diff --git a/package.json b/package.json index 34c052638..decdb221b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "@babel/register": "^7.0.0", "babel-jest": "^27.3.1", "benchmark": "^2.1.4", + "commander": "^9.2.0", + "deptree": "^1.0.0", "eslint": "^8.7.0", "eslint-config-prettier": "^8.1.0", "eslint-config-standard": "^17.0.0", @@ -23,6 +25,7 @@ "lodash": "^4.17.4", "prettier": "^2.0.5", "promise-toolbox": "^0.21.0", + "semver": "^7.3.6", "sorted-object": "^2.0.1", "vuepress": "^1.4.1" }, diff --git a/scripts/gen-deps-list.js b/scripts/gen-deps-list.js new file mode 100755 index 000000000..aecc09fbe --- /dev/null +++ b/scripts/gen-deps-list.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +'use strict' + +const { program, Argument } = require('commander') +const DepTree = require('deptree') +const fs = require('fs').promises +const joinPath = require('path').join +const semver = require('semver') +const { getPackages } = require('./utils') +const escapeRegExp = require('lodash/escapeRegExp') +const invert = require('lodash/invert') + +const changelogConfig = { + path: joinPath(__dirname, '../CHANGELOG.unreleased.md'), + startTag: '', + endTag: '', +} + +program + .argument('', 'The name of the package to release') + .addArgument(new Argument('', 'The type of release to perform').choices(['patch', 'minor', 'major'])) + .option('-r, --read-changelog', 'Import existing packages from the changelog') + .option('-w, --write-changelog', 'Write output to the changelog') + .option('--force', 'Required when using --write-changelog without --read-changelog') + .showHelpAfterError(true) + .showSuggestionAfterError(true) + .parse() + +const [rootPackageName, rootReleaseType] = program.args +const { readChangelog, writeChangelog, force } = program.opts() + +if (writeChangelog && !readChangelog && !force) { + // Stop the process to prevent unwanted changelog overwrite + program.showHelpAfterError(false).error(` + WARNING: Using --write-changelog without --read-changelog will remove existing packages list. + If you are sure you want to do this, add --force. + `) +} + +const RELEASE_WEIGHT = { PATCH: 1, MINOR: 2, MAJOR: 3 } +const RELEASE_TYPE = invert(RELEASE_WEIGHT) +const rootReleaseWeight = releaseTypeToWeight(rootReleaseType) + +/** @type {Map} A mapping of package names to their release weight */ +const packagesToRelease = new Map([[rootPackageName, rootReleaseWeight]]) + +const dependencyTree = new DepTree() + +async function main() { + if (readChangelog) { + await importPackagesFromChangelog() + } + + const packages = await getPackages(true) + const rootPackage = packages.find(pkg => pkg.name === rootPackageName) + + if (!rootPackage) { + program.showHelpAfterError(false).error(`error: Package ${rootPackageName} not found`) + } + + dependencyTree.add(rootPackage.name) + + /** + * Recursively add dependencies to the dependency tree + * + * @param {string} handledPackageName The name of the package to handle + * @param {string} handledPackageNextVersion The next version of the package to handle + */ + function handlePackage(handledPackageName, handledPackageNextVersion) { + packages.forEach(({ package: { name, version, dependencies, optionalDependencies, peerDependencies } }) => { + let releaseWeight + + if ( + shouldPackageBeReleased( + handledPackageName, + { ...dependencies, ...optionalDependencies }, + handledPackageNextVersion + ) + ) { + releaseWeight = RELEASE_WEIGHT.PATCH + } else if (shouldPackageBeReleased(handledPackageName, peerDependencies || {}, handledPackageNextVersion)) { + releaseWeight = versionToReleaseWeight(version) + } + + if (releaseWeight !== undefined) { + registerPackageToRelease(name, releaseWeight) + dependencyTree.add(name, handledPackageName) + handlePackage(name, getNextVersion(version, releaseWeight)) + } + }) + } + + handlePackage(rootPackage.name, getNextVersion(rootPackage.version, rootReleaseWeight)) + + const outputLines = dependencyTree.resolve().map(dependencyName => { + const releaseTypeName = RELEASE_TYPE[packagesToRelease.get(dependencyName)].toLocaleLowerCase() + return `- ${dependencyName} ${releaseTypeName}` + }) + + const outputLog = ['', 'New packages list:', '', ...outputLines] + + if (writeChangelog) { + await updateChangelog(outputLines) + outputLog.unshift('', `File updated: ${changelogConfig.path}`) + } + + console.log(outputLog.join('\n')) +} + +function releaseTypeToWeight(type) { + return RELEASE_WEIGHT[type.toLocaleUpperCase()] +} + +/** + * @param {string} name The package name to check + * @param {object} dependencies The package dependencies name/constraint map + * @param {string} version The version to check the dependency constraint against + * @returns {boolean} + */ +function shouldPackageBeReleased(name, dependencies, version) { + if (!Object.prototype.hasOwnProperty.call(dependencies, name)) { + return false + } + + return ( + ['xo-web', 'xo-server', '@xen-orchestra/proxy'].includes(name) || !semver.satisfies(version, dependencies[name]) + ) +} + +/** + * @param {string} packageName + * @param {int} releaseWeight + */ +function registerPackageToRelease(packageName, releaseWeight) { + const currentWeight = packagesToRelease.get(packageName) || 0 + + packagesToRelease.set(packageName, Math.max(currentWeight, releaseWeight)) +} + +/** + * @param {string} version + * @returns {int} + */ +function versionToReleaseWeight(version) { + return semver.major(version) > 0 + ? RELEASE_WEIGHT.MAJOR + : semver.minor(version) > 0 + ? RELEASE_WEIGHT.MINOR + : RELEASE_WEIGHT.PATCH +} + +/** + * @param {string} version The version to increment + * @param {int} releaseWeight The release weight to apply + * @returns {string} The incremented version + */ +function getNextVersion(version, releaseWeight) { + return semver.inc(version, RELEASE_TYPE[releaseWeight]) +} + +const changelogRegex = new RegExp( + `${escapeRegExp(changelogConfig.startTag)}(.*)${escapeRegExp(changelogConfig.endTag)}`, + 's' +) + +async function importPackagesFromChangelog() { + const content = await fs.readFile(changelogConfig.path) + const block = changelogRegex.exec(content)?.[1].trim() + + if (block === undefined) { + throw new Error(`Could not find changelog block in ${changelogConfig.path}`) + } + + const lines = block.matchAll(/^- (?[^ ]+) (?patch|minor|major)$/gm) + + for (const { groups: { name, type } } of lines) { + registerPackageToRelease(name, releaseTypeToWeight(type)) + dependencyTree.add(name) + } +} + +async function updateChangelog(lines) { + const content = await fs.readFile(changelogConfig.path) + await fs.writeFile( + changelogConfig.path, + content + .toString() + .replace(changelogRegex, [changelogConfig.startTag, '', ...lines, '', changelogConfig.endTag].join('\n')) + ) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/yarn.lock b/yarn.lock index f39a12a7a..d98a6a705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5448,6 +5448,11 @@ commander@^8.3.0: resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== +commander@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9" + integrity sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w== + commander@~2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" @@ -12263,7 +12268,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.0.4: +lru-cache@^7.4.0: version "7.8.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.8.1.tgz#68ee3f4807a57d2ba185b7fd90827d5c21ce82bb" integrity sha512-E1v547OCgJvbvevfjgK9sNKIVXO96NnsTsFPBlg4ZxjhsJSODoH9lk8Bm0OxvHNm6Vm5Yqkl/1fErDxhYL8Skg== @@ -16341,6 +16346,32 @@ semver@^7.0.0, semver@^7.3.2, semver@^7.3.5: dependencies: lru-cache "^6.0.0" +semver@^7.3.6: + version "7.3.6" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.6.tgz#5d73886fb9c0c6602e79440b97165c29581cbb2b" + integrity sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w== + dependencies: + lru-cache "^7.4.0" + +send@0.17.2: + version "0.17.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820" + integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww== + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "1.8.1" + mime "1.6.0" + ms "2.1.3" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + send@0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"