diff --git a/package.json b/package.json index b9792c993d9aa..3b2763f1ef893 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "release:publish": "pnpm publish --recursive --tag latest-v7", "release:publish:dry-run": "pnpm publish --recursive --tag latest-v7 --registry=\"http://localhost:4873/\"", "release:tag": "node scripts/releaseTag.mjs", + "release:prepare": "node scripts/createReleasePR.mjs", "validate": "concurrently \"pnpm prettier && pnpm eslint\" \"pnpm proptypes\" \"pnpm docs:typescript:formatted\" \"pnpm docs:api\"", "clean:node_modules": "rimraf --glob \"**/node_modules\"" }, @@ -152,12 +153,14 @@ "eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-testing-library": "^6.3.0", + "execa": "^9.4.0", "fast-glob": "^3.3.2", "format-util": "^1.0.5", "fs-extra": "^11.2.0", "glob-gitignore": "^1.0.15", "globby": "^14.0.2", "html-webpack-plugin": "^5.6.0", + "inquirer": "^9.2.15", "jsdom": "25.0.1", "jss": "^10.10.0", "jss-plugin-template": "^10.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e4feebaad89d..558115dccb18e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: eslint-plugin-testing-library: specifier: ^6.3.0 version: 6.3.0(eslint@8.57.1)(typescript@5.8.3) + execa: + specifier: ^9.4.0 + version: 9.5.2 fast-glob: specifier: ^3.3.2 version: 3.3.2 @@ -287,6 +290,9 @@ importers: html-webpack-plugin: specifier: ^5.6.0 version: 5.6.0(webpack@5.95.0) + inquirer: + specifier: ^9.2.15 + version: 9.3.7 jsdom: specifier: 25.0.1 version: 25.0.1 @@ -2977,6 +2983,10 @@ packages: cpu: [x64] os: [win32] + '@inquirer/figures@1.0.12': + resolution: {integrity: sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==} + engines: {node: '>=18'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -5003,6 +5013,10 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -6702,6 +6716,10 @@ packages: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} + inquirer@9.3.7: + resolution: {integrity: sha512-LJKFHCSeIRq9hanN14IlOtPSTe3lNES7TYDTE2xxdAy1LS5rYphajK1qtwvj3YmQXvvk0U2Vbmcni8P9EIQW9w==} + engines: {node: '>=18'} + internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -8932,6 +8950,10 @@ packages: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -10165,6 +10187,10 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + yoctocolors@2.0.2: resolution: {integrity: sha512-Ct97huExsu7cWeEjmrXlofevF8CvzUglJ4iGUet5B8xn1oumtAZBpHU4GzYuoE6PVqcZ5hghtBrSlhwHuR1Jmw==} engines: {node: '>=18'} @@ -11607,6 +11633,8 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/figures@1.0.12': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -14038,6 +14066,8 @@ snapshots: cli-width@3.0.0: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} clipboard-copy@4.0.1: {} @@ -16144,6 +16174,21 @@ snapshots: through: 2.3.8 wrap-ansi: 6.2.0 + inquirer@9.3.7: + dependencies: + '@inquirer/figures': 1.0.12 + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + external-editor: 3.1.0 + mute-stream: 1.0.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + internal-slot@1.0.7: dependencies: es-errors: 1.3.0 @@ -18752,6 +18797,8 @@ snapshots: run-async@2.4.1: {} + run-async@3.0.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -20125,6 +20172,8 @@ snapshots: yocto-queue@1.0.0: {} + yoctocolors-cjs@2.1.2: {} + yoctocolors@2.0.2: {} zip-stream@4.1.1: diff --git a/scripts/README.md b/scripts/README.md index ba31eccb48a67..ce87df354c919 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,6 +14,23 @@ A typical release goes like this: ### Prepare the release of the packages +> [!INFO] +> You can now use the new automated release preparation script by running `pnpm release:prepare`. This script automates steps 1-5 below by: +> +> - Asking for the major version to update (v7.x, v6.x, etc.) +> - Determining the new version based on the selected major version: +> - For non-latest major versions: patch/minor/custom +> - For latest major version: patch/minor/major/custom and prerelease options: +> - Start alpha prerelease (if no prerelease exists) +> - Increase alpha version or start beta (if alpha exists) +> - Increase beta version or go to major (if beta exists) +> - Creating a new branch from upstream/master (for latest major) or upstream/vX.x (for older versions) +> - Updating the root package.json and all product package versions +> - Generating and formatting the changelog +> - Creating a PR with all changes and a complete checklist +> +> This script is fully interactive and will guide you through the release process. + The following steps must be proposed as a pull request. 1. Compare the last tag with the branch upon which you want to release. diff --git a/scripts/changelogUtils.mjs b/scripts/changelogUtils.mjs new file mode 100644 index 0000000000000..d4511376b8ce6 --- /dev/null +++ b/scripts/changelogUtils.mjs @@ -0,0 +1,611 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-syntax */ +/** + * This module provides utilities for generating a changelog for MUI X packages. + * + * Features: + * - Fetches commits between two Git references (tags/branches) + * - Categorizes commits based on tags in commit messages and PR labels + * - Generates a changelog with sections for different packages + * - Uses actual versions from package.json files + * - Can return the changelog as a string when returnEntry is true + */ +import fs from 'fs'; +import path from 'path'; + +const ORG = 'mui'; +const REPO = 'mui-x'; + +/** + * @type {string[]} + * Labels to exclude from the changelog + */ +const excludeLabels = ['dependencies', 'scope: scheduler']; + +/** + * @type {string[]} + * Tags found in title to exclude the commit from the changelog + */ +const excludeTitleTags = ['[charts-premium]']; + +/** + * @type {string} + * Formatted current date for the changelog + */ +const nowFormatted = new Date().toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +/** + * Global variable to store the Octokit instance + * @type {import('@octokit/rest').Octokit | null} + */ +let octokit = null; + +/** + * Get the version of a package from its package.json file + * @param {string} packageName - The name of the package (e.g. 'x-data-grid') + * @returns {string} The version of the package + */ +function getPackageVersion(packageName) { + try { + // Construct the path to the package.json file + const packageJsonPath = path.join(process.cwd(), 'packages', packageName, 'package.json'); + + // Check if the file exists + if (!fs.existsSync(packageJsonPath)) { + console.warn(`Package.json not found for ${packageName} at ${packageJsonPath}`); + return '__VERSION__'; // Fallback to placeholder + } + + // Read and parse the package.json file + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + // Check if the version field exists + if (!packageJson.version) { + console.warn(`No version field found in package.json for ${packageName}`); + return '__VERSION__'; // Fallback to placeholder + } + + return packageJson.version; + } catch (error) { + console.error(`Error reading package.json for ${packageName}:`, error); + return '__VERSION__'; // Fallback to placeholder + } +} + +/** + * Removes duplicate empty lines from a string. + * @param {string} text - The text to process + * @returns {string} The text with duplicate empty lines removed + */ +function removeDuplicateEmptyLines(text) { + return text + .replace(/\n\s*\n\s*\n/g, '\n\n') // Replace multiple empty lines with double line break + .trim(); // Remove leading/trailing whitespace +} + +/** + * @param {string} commitMessage + * @returns {string} The tags in lowercases, ordered ascending and comma-separated + */ +function parseTags(commitMessage) { + const tagMatch = commitMessage.match(/^(\[[\w- ]+\])+/); + if (tagMatch === null) { + return ''; + } + const [tagsWithBracketDelimiter] = tagMatch; + return tagsWithBracketDelimiter + .match(/([\w- ]+)/g) + .map((tag) => { + return tag; + }) + .sort((a, b) => { + return a.localeCompare(b); + }) + .join(','); +} + +/** + * Find the latest tagged version from GitHub + * @returns {Promise} The latest tagged version + */ +async function findLatestTaggedVersion() { + // fetch tags from the GitHub API and return the last one + const { data: tags } = await octokit.rest.repos.listTags({ + owner: ORG, + repo: REPO, + }); + return tags[0].name.trim(); +} + +function resolvePackagesByLabels(labels) { + const resolvedPackages = []; + labels.forEach((label) => { + switch (label.name) { + case 'scope: data grid': + resolvedPackages.push('DataGrid'); + break; + case 'scope: pickers': + resolvedPackages.push('pickers'); + break; + case 'scope: charts': + resolvedPackages.push('charts'); + break; + case 'scope: tree view': + resolvedPackages.push('TreeView'); + break; + case 'scope: scheduler': + resolvedPackages.push('Scheduler'); + break; + default: + break; + } + }); + return resolvedPackages; +} + +/** + * Generates a changelog for MUI X packages + * @param {object} options - The options for generating the changelog + * @param {import('@octokit/rest').Octokit} options.octokit - The Octokit instance to use for GitHub API calls + * @param {string} [options.lastRelease] - The release to compare against + * @param {string} options.release - The release to generate the changelog for + * @param {string} [options.nextVersion] - The version expected to be released + * @param {boolean} [options.returnEntry] - Whether to return the changelog as a string + * @returns {Promise} The changelog string or null + */ +export async function generateChangelog({ + octokit: octokitInput, + lastRelease: lastReleaseInput, + release = 'master', + nextVersion, + returnEntry = false, +}) { + octokit = octokitInput; + + // fetch the last tag and chose the one to use for the release + const latestTaggedVersion = await findLatestTaggedVersion(); + const lastRelease = lastReleaseInput !== undefined ? lastReleaseInput : latestTaggedVersion; + if (lastRelease !== latestTaggedVersion) { + console.warn( + `Creating changelog for ${lastRelease}..${release} when latest tagged version is '${latestTaggedVersion}'.`, + ); + } + + // Now We will fetch all the commits between the chosen tag and release branch + /** + * @type {AsyncIterableIterator>} + */ + const timeline = octokit.paginate.iterator( + octokit.repos.compareCommits.endpoint.merge({ + owner: ORG, + repo: REPO, + base: lastRelease, + head: release, + }), + ); + + /** + * @type {import('@octokit/rest').Octokit.ReposCompareCommitsResponseCommitsItem[]} + */ + const commitsItems = []; + for await (const response of timeline) { + const { data: compareCommits } = response; + commitsItems.push(...compareCommits.commits); + } + + // Fetch all the pull Request and check if there is a section named changelog + const changeLogMessages = {}; + const prsLabelsMap = {}; + const community = { + firstTimers: new Set(), + contributors: new Set(), + team: new Set(), + }; + await Promise.all( + commitsItems.map(async (commitsItem) => { + const searchPullRequestId = commitsItem.commit.message.match(/\(#([0-9]+)\)/); + if (!searchPullRequestId || !searchPullRequestId[1]) { + return; + } + + const { + data: { + body: bodyMessage, + labels, + // eslint-disable-next-line @typescript-eslint/naming-convention + author_association, + user: { login }, + }, + } = await octokit.rest.pulls.get({ + owner: ORG, + repo: REPO, + pull_number: Number(searchPullRequestId[1]), + }); + + // Skip bot accounts + if (!login.includes('[bot]')) { + switch (author_association) { + case 'CONTRIBUTOR': + community.contributors.add(`@${login}`); + break; + case 'FIRST_TIMER': + community.firstTimers.add(`@${login}`); + break; + case 'MEMBER': + community.team.add(`@${login}`); + break; + default: + } + } + + prsLabelsMap[commitsItem.sha] = labels; + + if (!bodyMessage) { + return; + } + + const changelogMotif = '## changelog'; + const lowercaseBody = bodyMessage.toLowerCase(); + // Check if the body contains a line starting with '## changelog' + const matchedChangelog = lowercaseBody.matchAll(new RegExp(`^${changelogMotif}`, 'gim')); + const changelogMatches = Array.from(matchedChangelog); + if (changelogMatches.length > 0) { + const prLabels = prsLabelsMap[commitsItem.sha]; + const resolvedPackage = resolvePackagesByLabels(prLabels)[0]; + const changelogIndex = changelogMatches[0].index; + const message = `From https://github.com/${ORG}/${REPO}/pull/${ + searchPullRequestId[1] + }\n${bodyMessage.slice(changelogIndex + changelogMotif.length)}`; + if (changeLogMessages[resolvedPackage || 'general']) { + changeLogMessages[resolvedPackage || 'general'].push(message); + } else { + changeLogMessages[resolvedPackage || 'general'] = [message]; + } + } + }), + ); + + // Dispatch commits in different sections + const dataGridCommits = []; + const dataGridProCommits = []; + const dataGridPremiumCommits = []; + const pickersCommits = []; + const pickersProCommits = []; + const chartsCommits = []; + const chartsProCommits = []; + const treeViewCommits = []; + const treeViewProCommits = []; + const schedulerCommits = []; + const schedulerProCommits = []; + const coreCommits = []; + const docsCommits = []; + const otherCommits = []; + const codemodCommits = []; + + commitsItems + .filter((item) => !prsLabelsMap[item.sha].some((label) => excludeLabels.includes(label.name))) + .filter((item) => !excludeTitleTags.some((tag) => item.commit.message.includes(tag))) + .forEach((commitItem) => { + const tag = parseTags(commitItem.commit.message); + // for now we use only one parsed tag + const firstTag = tag.split(',')[0]; + switch (firstTag) { + case 'DataGrid': + case 'data grid': + dataGridCommits.push(commitItem); + break; + case 'DataGridPro': + dataGridProCommits.push(commitItem); + break; + case 'DataGridPremium': + dataGridPremiumCommits.push(commitItem); + break; + case 'DatePicker': + case 'TimePicker': + case 'DateTimePicker': + case 'pickers': + case 'fields': + pickersCommits.push(commitItem); + break; + case 'DateRangePicker': + case 'DateTimeRangePicker': + case 'TimeRangePicker': + pickersProCommits.push(commitItem); + break; + case 'charts-pro': + chartsProCommits.push(commitItem); + break; + case 'charts': + chartsCommits.push(commitItem); + break; + case 'TreeView': + case 'RichTreeView': + case 'tree view': + case 'TreeItem': + treeViewCommits.push(commitItem); + break; + case 'RichTreeViewPro': + case 'tree view pro': + treeViewProCommits.push(commitItem); + break; + case 'scheduler': + schedulerCommits.push(commitItem); + break; + case 'scheduler-pro': + schedulerProCommits.push(commitItem); + break; + case 'docs': + docsCommits.push(commitItem); + break; + case 'core': + coreCommits.push(commitItem); + break; + case 'codemod': + codemodCommits.push(commitItem); + break; + case 'l10n': + case '118n': { + const prLabels = prsLabelsMap[commitItem.sha]; + const resolvedPackages = resolvePackagesByLabels(prLabels); + if (resolvedPackages.length > 0) { + resolvedPackages.forEach((resolvedPackage) => { + switch (resolvedPackage) { + case 'DataGrid': + dataGridCommits.push(commitItem); + break; + case 'pickers': + pickersCommits.push(commitItem); + break; + case 'Scheduler': + schedulerCommits.push(commitItem); + break; + default: + coreCommits.push(commitItem); + break; + } + }); + } else { + otherCommits.push(commitItem); + } + break; + } + default: + otherCommits.push(commitItem); + break; + } + }); + + // Helper to print a list of commits in a section of the changelog + const logCommitEntries = (commitsList) => { + if (commitsList.length === 0) { + return ''; + } + + const sortedCommits = commitsList.sort((a, b) => { + const aTags = parseTags(a.commit.message); + const bTags = parseTags(b.commit.message); + if (aTags === bTags) { + return a.commit.message < b.commit.message ? -1 : 1; + } + return aTags.localeCompare(bTags); + }); + + return sortedCommits + .map((commit) => `- ${commit.commit.message.split('\n')[0]} @${commit.author.login}`) + .join('\n'); + }; + + const proIcon = + '[![pro](https://mui.com/r/x-pro-svg)](https://mui.com/r/x-pro-svg-link "Pro plan")'; + const premiumIcon = + '[![premium](https://mui.com/r/x-premium-svg)](https://mui.com/r/x-premium-svg-link "Premium plan")'; + + /** + * Generates a changelog section for a product + * @param {object} options - The options for generating the product section + * @param {string} options.productName - The display name of the product (e.g., 'Data Grid', 'Charts') + * @param {string} options.packageName - The base package name (e.g., 'x-data-grid', 'x-charts') + * @param {import('@octokit/rest').Octokit.ReposCompareCommitsResponseCommitsItem[]} options.baseCommits - The commits for the base package + * @param {import('@octokit/rest').Octokit.ReposCompareCommitsResponseCommitsItem[]} [options.proCommits] - The commits for the Pro package (if applicable) + * @param {import('@octokit/rest').Octokit.ReposCompareCommitsResponseCommitsItem[]} [options.premiumCommits] - The commits for the Premium package (if applicable) + * @param {string} [options.changelogKey] - The key to use for changelog messages (e.g., 'DataGrid', 'charts') + * @returns {string} The formatted changelog section for the product + */ + const logProductSection = ({ + productName, + packageName, + baseCommits, + proCommits = null, + premiumCommits = null, + changelogKey, + }) => { + const hasProVersion = proCommits !== null; + const hasPremiumVersion = premiumCommits !== null; + const packageVersion = nextVersion ? getPackageVersion(packageName) : '__VERSION__'; + + const lines = [`### ${productName}`]; + + // Add changelog messages if available + if (changeLogMessages[changelogKey]?.length > 0) { + lines.push(...changeLogMessages[changelogKey]); + } + + // Base package + lines.push(`#### \`@mui/${packageName}@${packageVersion}\``); + lines.push(`${logCommitEntries(baseCommits) || 'Internal changes.'}`); + + // Pro package (if applicable) + if (hasProVersion) { + lines.push(`#### \`@mui/${packageName}-pro@${packageVersion}\` ${proIcon}`); + + if (proCommits?.length > 0) { + lines.push(`Same changes as in \`@mui/${packageName}@${packageVersion}\`, plus:`); + lines.push(logCommitEntries(proCommits)); + } else { + lines.push(`Same changes as in \`@mui/${packageName}@${packageVersion}\`.`); + } + } + + // Premium package (if applicable) + if (hasPremiumVersion) { + lines.push(`#### \`@mui/${packageName}-premium@${packageVersion}\` ${premiumIcon}`); + + if (premiumCommits?.length > 0) { + lines.push(`Same changes as in \`@mui/${packageName}-pro@${packageVersion}\`, plus:`); + lines.push(logCommitEntries(premiumCommits)); + } else { + lines.push(`Same changes as in \`@mui/${packageName}-pro@${packageVersion}\`.`); + } + } + + return lines.join('\n\n'); + }; + + /** + * Generates a changelog section for a product + * @param {object} options - The options for generating the product section + * @param {string} options.sectionName - The name of the section (e.g., 'Docs', 'Core', 'Miscellaneous') + * @param {import('@octokit/rest').Octokit.ReposCompareCommitsResponseCommitsItem[]} options.commits - The commits to log for the section + * @returns {string} The formatted changelog section for the product + */ + const logOtherSection = (options) => { + const { sectionName, commits } = options; + + if (commits.length === 0) { + return ''; + } + + const lines = [`### ${sectionName}`]; + + // Add changelog messages if available + if (changeLogMessages[sectionName]?.length > 0) { + lines.push(...changeLogMessages[sectionName]); + } + + lines.push(logCommitEntries(commits) || 'Internal changes.'); + + return lines.join('\n\n'); + }; + + // Log the general section of the changelog + const logGeneralSection = () => { + const authorsCount = + community.contributors.size + community.firstTimers.size + community.team.size; + const lines = [ + `We'd like to extend a big thank you to the ${authorsCount} contributors who made this release possible. Here are some highlights ✨:`, + 'TODO INSERT HIGHLIGHTS', + ]; + + if (changeLogMessages.general?.length) { + lines.push(...changeLogMessages.general); + } + + // TODO: separate first timers and regular contributors + const contributors = [ + ...Array.from(community.contributors), + ...Array.from(community.firstTimers), + ].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + const teamMembers = Array.from(community.team).sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + + if (contributors.length > 0) { + lines.push( + `Special thanks go out to the community members for their valuable contributions:\n${contributors.join(', ')}`, + ); + } + + if (community.team.size > 0) { + lines.push( + `The following are all team members who have contributed to this release:\n${teamMembers.join(', ')}`, + ); + } + + return lines.join('\n\n'); + }; + + const changelog = removeDuplicateEmptyLines(` +## ${nextVersion || '__VERSION__'} + +_${nowFormatted}_ + +${logGeneralSection()} + +${logProductSection({ + productName: 'Data Grid', + packageName: 'x-data-grid', + baseCommits: dataGridCommits, + proCommits: dataGridProCommits, + premiumCommits: dataGridPremiumCommits, + changelogKey: 'DataGrid', +})} + +${logProductSection({ + productName: 'Date and Time Pickers', + packageName: 'x-date-pickers', + baseCommits: pickersCommits, + proCommits: pickersProCommits, + changelogKey: 'pickers', +})} + +${logProductSection({ + productName: 'Charts', + packageName: 'x-charts', + baseCommits: chartsCommits, + proCommits: chartsProCommits, + changelogKey: 'charts', +})} + +${logProductSection({ + productName: 'Tree View', + packageName: 'x-tree-view', + baseCommits: treeViewCommits, + proCommits: treeViewProCommits, + changelogKey: 'TreeView', +})} + +${logProductSection({ + productName: 'Codemod', + packageName: 'x-codemod', + baseCommits: codemodCommits, + changelogKey: 'codemod', +})} + +${logOtherSection({ + sectionName: 'Docs', + commits: docsCommits, +})} + +${logOtherSection({ + sectionName: 'Core', + commits: coreCommits, +})} + +${logOtherSection({ + sectionName: 'Miscellaneous', + commits: otherCommits, +})} +`); + + try { + if (returnEntry) { + // Return the string if returnEntry is true + return changelog; + } + // Otherwise log it to the console + // eslint-disable-next-line no-console -- output of this script + console.log(changelog); + return null; + } catch (error) { + console.error('Error generating changelog:', error); + if (returnEntry) { + throw error; // Re-throw the error when in returnEntry mode + } + return null; + } +} diff --git a/scripts/createReleasePR.mjs b/scripts/createReleasePR.mjs new file mode 100644 index 0000000000000..8935fe19b4637 --- /dev/null +++ b/scripts/createReleasePR.mjs @@ -0,0 +1,1098 @@ +#!/usr/bin/env node +/* eslint-disable no-console,consistent-return */ +/** + * MUI-X Release Preparation Script + * + * This script automates the release preparation process for MUI-X: + * 1. Asking for the major version to update (v7.x, v6.x, etc.) + * 2. Creating a release branch + * 3. Determining the new version: + * - For non-latest major versions: patch/minor/custom + * - For latest major version: patch/minor/major/custom and prerelease options: + * - Start alpha prerelease (if no prerelease exists) + * - Increase alpha version or start beta (if alpha exists) + * - Increase beta version or go to major (if beta exists) + * 4. Creating a new branch from upstream/master (for latest major) or upstream/vX.x (for older versions) + * 5. Updating the root package.json with the new version + * 6. Running the lerna version script to update all package versions + * 7. Generating the changelog with actual package versions + * 8. Adding the new changelog entry to the CHANGELOG.md file + * 9. Waiting for user confirmation to review changes + * 10. Committing the changes to the branch + * 11. Opening a PR with a title "[release] v" and label "release" + * with a checklist of all release steps + */ + +import { execa } from 'execa'; +import { Octokit } from '@octokit/rest'; +import { retry } from '@octokit/plugin-retry'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import fs from 'fs/promises'; +import path from 'path'; +import inquirer from 'inquirer'; +import { generateChangelog as generateChangelogFromModule } from './changelogUtils.mjs'; +import pck from '../package.json' with { type: 'json' }; + +const packageVersion = pck.version; + +/** + * Create a custom Octokit class with retry functionality + * @type {typeof import('@octokit/rest').Octokit} + */ +const MyOctokit = Octokit.plugin(retry); + +/** + * Global variable to store the Octokit instance + * @type {import('@octokit/rest').Octokit | null} + */ +let octokit = null; + +const ORG = 'mui'; +const REPO = 'mui-x'; + +// we need to disable the no-useless-escape to include the `/` in the regex single character capturing group +// eslint-disable-next-line no-useless-escape +const getRemoteRegex = (owner) => new RegExp(`([\/:])${owner}\/${REPO}(\.git)?\s+\(push\)`); + +/** + * Command line arguments for the script + * @typedef {object} ArgvOptions + * @property {boolean} [patch] - Create a patch release + * @property {boolean} [minor] - Create a minor release + * @property {boolean} [major] - Create a major release + * @property {string} [custom] - Create a release with a custom version number + * @property {string} [githubToken] - GitHub token for authentication + */ + +/** + * Parse command line arguments + * @type {ArgvOptions} + */ +const argv = yargs(hideBin(process.argv)) + .option('patch', { + type: 'boolean', + description: 'Create a patch release', + }) + .option('minor', { + type: 'boolean', + description: 'Create a minor release', + }) + .option('major', { + type: 'boolean', + description: 'Create a major release', + }) + .option('custom', { + type: 'string', + description: 'Create a release with a custom version number', + }) + .help() + .alias('help', 'h') + .parseSync(); + +/** + * Find the remote pointing to mui/mui-x + * @returns {Promise} The name of the remote + */ +async function findMuiXRemote() { + try { + const { stdout } = await execa('git', ['remote', '-v']); + const remotes = stdout.split('\n'); + // we need to disable the no-useless-escape to include the `/` in the regex single character capturing group + // eslint-disable-next-line no-useless-escape + const rx = getRemoteRegex(ORG); + + console.log('Checking for MUI-X remote...', stdout); + + let upstreamRemote = ''; + for (const line of remotes) { + if (line.match(rx)) { + upstreamRemote = line.split(/\s+/)[0]; + break; + } + } + + if (!upstreamRemote) { + console.error( + "Error: Unable to find the upstream remote. It should be a remote pointing to 'mui/mui-x'.", + ); + console.error( + "Did you forget to add it via 'git remote add upstream git@github.com:mui/mui-x.git'?", + ); + process.exit(1); + } + + return upstreamRemote; + } catch (error) { + console.error('Error finding MUI-X remote:', error); + process.exit(1); + } +} + +/** + * Find the username or organization name from the authenticated GitHub user + * @returns {Promise} The username or organization name + */ +async function findForkOwner() { + try { + console.log('Getting authenticated GitHub user...'); + + // Get the authenticated user from GitHub API + const { data: user } = await octokit.rest.users.getAuthenticated(); + const owner = user.login; + + if (!owner) { + console.error('Error: Unable to get the authenticated GitHub user.'); + process.exit(1); + } + + console.log(`Found authenticated user: ${owner}`); + return owner; + } catch (error) { + console.error('Error finding authenticated user:', error); + process.exit(1); + } +} + +/** + * Find the remote name of the fork for the repo + * @returns {Promise} The name of the remote + */ +async function findForkRemote() { + try { + // Get the fork owner (username) + const forkOwner = await findForkOwner(); + + // Get all remotes + const { stdout } = await execa('git', ['remote', '-v']); + const remotes = stdout.split('\n'); + + console.log('Checking for fork remote...'); + + // Look for a remote that points to the fork owner's repository + let forkRemote = ''; + for (const line of remotes) { + // we need to disable the no-useless-escape to include the `/` in the regex single character capturing group + // eslint-disable-next-line no-useless-escape + const rx = getRemoteRegex(forkOwner); + + if (line.match(rx)) { + forkRemote = line.split(/\s+/)[0]; + break; + } + } + + // If no fork remote is found, default to 'origin' + if (!forkRemote) { + console.log('No specific fork remote found, defaulting to "origin"'); + return 'origin'; + } + + console.log(`Found fork remote: ${forkRemote}`); + return forkRemote; + } catch (error) { + console.error('Error finding fork remote:', error); + console.log('Defaulting to "origin" as fork remote'); + return 'origin'; + } +} + +/** + * Select the major version to update + * @returns {Promise} The selected major version + */ +async function selectMajorVersion() { + const currentMajorVersion = packageVersion.split('.')[0]; + + const selection = await inquirer.prompt([ + { + type: 'input', + name: 'majorVersion', + message: 'Please select the major version you are trying to update:', + default: currentMajorVersion, + validate: (input) => { + if (!/^\d+$/.test(input)) { + return 'Major version must be a number'; + } + + if (parseInt(input, 10) > parseInt(currentMajorVersion, 10)) { + return `Cannot select a major version (${input}) higher than the current major version (${currentMajorVersion})`; + } + + return true; + }, + }, + ]); + + console.log(`Selected major version: ${selection.majorVersion}`); + return selection.majorVersion; +} + +/** + * Calculate versions from git tags + * @param {string} majorVersion - The selected major version + * @returns {Promise<{ + * success: boolean, + * nextPatch?: string, + * nextMinor?: string, + * nextMajor?: string + * }>} + */ +async function calculateVersionsFromTags(majorVersion) { + try { + // Get all tags matching the selected major version + const { stdout: tagsOutput } = await execa('git', ['tag', '-l', `v${majorVersion}.*`]); + const tags = tagsOutput + .split('\n') + .filter(Boolean) + .sort((a, b) => { + // Sort versions using semver logic + const aParts = a.substring(1).split('.').map(Number); + const bParts = b.substring(1).split('.').map(Number); + + for (let i = 0; i < 3; i += 1) { + if (aParts[i] !== bParts[i]) { + return aParts[i] - bParts[i]; + } + } + + return 0; + }); + + const latestTag = tags[tags.length - 1]; + + if (!latestTag) { + console.warn(`Warning: No tags found for major version ${majorVersion}`); + console.warn('Will calculate versions from package.json instead'); + return { success: false }; + } + + console.log(`Latest tag: ${latestTag}`); + + // Remove the 'v' prefix + const versionWithoutV = latestTag.substring(1); + + // Split the version into components + const [tagMajor, tagMinor, tagPatch] = versionWithoutV.split('.').map(Number); + + // Calculate next versions + const nextPatchVersion = `${tagMajor}.${tagMinor}.${tagPatch + 1}`; + const nextMinorVersion = `${tagMajor}.${tagMinor + 1}.0`; + const nextMajorVersion = `${tagMajor + 1}.0.0`; + + return { + success: true, + nextPatch: nextPatchVersion, + nextMinor: nextMinorVersion, + nextMajor: nextMajorVersion, + }; + } catch (error) { + console.error('Error calculating versions from tags:', error); + return { success: false }; + } +} + +/** + * Select the version type based on the current version and selected major version + * @param {string} majorVersion - The selected major version + * @returns {Promise<{ + * versionType: 'patch' | 'minor' | 'major' | 'prerelease' | 'custom', + * calculatedVersion?: string, + * customVersion?: string, + * prereleaseType?: 'alpha' | 'beta', + * prereleaseNumber?: number + * }>} Object containing version information + */ +async function selectVersionType(majorVersion) { + console.log(`Fetching latest tag for major version ${majorVersion}...`); + + const { success, nextPatch, nextMinor, nextMajor } = + await calculateVersionsFromTags(majorVersion); + + let nextPatchDisplay = nextPatch; + let nextMinorDisplay = nextMinor; + let nextMajorDisplay = nextMajor; + + if (!success) { + // Use generic placeholders if no tags found + nextPatchDisplay = 'x.x.X'; + nextMinorDisplay = 'x.X.0'; + nextMajorDisplay = 'X.0.0'; + } + + // Check if the selected major version is the latest one + const currentMajorVersion = packageVersion.split('.')[0]; + const isLatestMajor = majorVersion === currentMajorVersion; + + // Check if current version is a prerelease (alpha or beta) + const alphaMatch = packageVersion.match(/-alpha\.(\d+)$/); + const betaMatch = packageVersion.match(/-beta\.(\d+)$/); + const isAlpha = !!alphaMatch; + const isBeta = !!betaMatch; + const alphaVersion = isAlpha ? parseInt(alphaMatch[1], 10) : 0; + const betaVersion = isBeta ? parseInt(betaMatch[1], 10) : 0; + + // Build choices array based on version type + const choices = [ + { name: `Patch (${nextPatchDisplay})`, value: 'patch' }, + { name: `Minor (${nextMinorDisplay})`, value: 'minor' }, + { name: 'Custom version', value: 'custom' }, + ]; + + // Handle prerelease options based on current version + if (isLatestMajor) { + if (isAlpha) { + // If alpha is present, give option to increase alpha or start beta + choices.splice( + 2, + 0, + { + name: `Increase Alpha (${currentMajorVersion}.0.0-alpha.${alphaVersion + 1})`, + value: 'alpha-increase', + }, + { name: `Start Beta (${currentMajorVersion}.0.0-beta.0)`, value: 'beta-start' }, + ); + } else if (isBeta) { + // If beta is present, give option to increase beta or go to major + choices.splice( + 2, + 0, + { + name: `Increase Beta (${currentMajorVersion}.0.0-beta.${betaVersion + 1})`, + value: 'beta-increase', + }, + { name: `Major (${nextMajorDisplay})`, value: 'major' }, + ); + } else { + // If no prerelease, give option for major or start prerelease + choices.splice(2, 0, { + name: `Pre-Release (${nextMajorDisplay}-alpha.0)`, + value: 'alpha-start', + }); + } + } + + // First prompt for version type + const { versionChoice } = await inquirer.prompt([ + { + type: 'list', + name: 'versionChoice', + message: 'Please select the version type:', + default: 'patch', + choices, + }, + ]); + + // Handle the selected version type + switch (versionChoice) { + case 'patch': { + console.log(`Selected: Patch (${nextPatchDisplay})`); + return { + versionType: 'patch', + calculatedVersion: nextPatch, + }; + } + case 'minor': { + console.log(`Selected: Minor (${nextMinorDisplay})`); + return { + versionType: 'minor', + calculatedVersion: nextMinor, + }; + } + case 'major': { + console.log(`Selected: Major (${nextMajorDisplay})`); + return { + versionType: 'major', + calculatedVersion: nextMajor, + }; + } + case 'custom': { + let defaultCustomVersion; + + if (success && nextPatch) { + // Use the calculated next patch version from tags + defaultCustomVersion = nextPatch; + } else { + // If no tags were found, fall back to calculating from package.json + const [defaultMajor, defaultMinor, defaultPatch] = packageVersion.split('.').map(Number); + defaultCustomVersion = `${defaultMajor}.${defaultMinor}.${defaultPatch + 1}`; + } + + const { customVersion } = await inquirer.prompt([ + { + type: 'input', + name: 'customVersion', + message: 'Enter custom version:', + default: defaultCustomVersion, + }, + ]); + + console.log(`Selected: Custom version ${customVersion}`); + return { + versionType: 'custom', + customVersion, + }; + } + case 'alpha-start': { + const calculatedVersion = `${nextMajor}-alpha.0`; + console.log(`Selected: Pre-Release (${calculatedVersion})`); + return { + versionType: 'prerelease', + calculatedVersion, + prereleaseType: 'alpha', + prereleaseNumber: 0, + }; + } + case 'alpha-increase': { + const calculatedVersion = `${currentMajorVersion}.0.0-alpha.${alphaVersion + 1}`; + console.log(`Selected: Increase Alpha (${calculatedVersion})`); + return { + versionType: 'prerelease', + calculatedVersion, + prereleaseType: 'alpha', + prereleaseNumber: alphaVersion + 1, + }; + } + case 'beta-start': { + const calculatedVersion = `${currentMajorVersion}.0.0-beta.0`; + console.log(`Selected: Start Beta (${calculatedVersion})`); + return { + versionType: 'prerelease', + calculatedVersion, + prereleaseType: 'beta', + prereleaseNumber: 0, + }; + } + case 'beta-increase': { + const calculatedVersion = `${currentMajorVersion}.0.0-beta.${betaVersion + 1}`; + console.log(`Selected: Increase Beta (${calculatedVersion})`); + return { + versionType: 'prerelease', + calculatedVersion, + prereleaseType: 'beta', + prereleaseNumber: betaVersion + 1, + }; + } + default: + // This shouldn't happen with inquirer's list type + throw new Error(`Unexpected version choice: ${versionChoice}`); + } +} + +/** + * Calculate the new version based on the selected version type and parameters + * @param {'patch' | 'minor' | 'major' | 'prerelease' | 'custom'} versionType - The selected version type + * @param {string} [calculatedVersion] - The calculated version from git tags (if available) + * @param {string} [customVersion] - The custom version entered by the user (if applicable) + * @param {'alpha' | 'beta' | undefined} [prereleaseType] - The type of prerelease (for prerelease versions) + * @param {number} [prereleaseNumber] - The prerelease version number (for prerelease versions) + * @returns {string} The new version string in semver format (e.g., '9.0.0', '9.0.0-alpha.1', '9.0.0-beta.0') + */ +function calculateNewVersion( + versionType, + calculatedVersion, + customVersion, + prereleaseType, + prereleaseNumber, +) { + if (customVersion) { + return customVersion; + } + + if (calculatedVersion) { + return calculatedVersion; + } + + // Fall back to calculating from package.json if no calculated version is available + const [major, minor, patch] = packageVersion.split('.').map(Number); + + if (versionType === 'patch') { + return `${major}.${minor}.${patch + 1}`; + } + + if (versionType === 'minor') { + return `${major}.${minor + 1}.0`; + } + + if (versionType === 'major') { + return `${major + 1}.0.0`; + } + + if (versionType === 'prerelease') { + if (prereleaseType === 'alpha') { + return `${major + 1}.0.0-alpha.${prereleaseNumber}`; + } + if (prereleaseType === 'beta') { + return `${major}.0.0-beta.${prereleaseNumber}`; + } + } + + return packageVersion; // Fallback to current version if something goes wrong +} + +/** + * Check for uncommitted changes + * @returns {Promise} + */ +async function checkUncommittedChanges() { + try { + let { stdout } = await execa('git', ['status', '--porcelain']); + if (!stdout) { + return; + } + while (stdout) { + console.warn('Warning: You have uncommitted changes.'); + console.warn('Please commit or stash your changes before continuing.'); + console.warn('You can run:'); + console.warn(' git add . && git commit -m "Your commit message"'); + console.warn(' or'); + console.warn(' git stash'); + console.warn('in another terminal window.'); + // eslint-disable-next-line no-await-in-loop + await inquirer.prompt([ + { + type: 'confirm', + name: 'continue', + message: 'Press Enter to check again, or Ctrl+C to abort...', + default: true, + }, + ]); + // eslint-disable-next-line no-await-in-loop + const result = await execa('git', ['status', '--porcelain']); + stdout = result.stdout; + } + } catch (error) { + console.error('Error checking for uncommitted changes:', error); + process.exit(1); + } +} + +/** + * Update the root package.json with the new version + * @param {string} newVersion - The new version + * @returns {Promise} + */ +async function updatePackageJson(newVersion) { + try { + console.log('Updating root package.json...'); + + const packageJsonPath = path.join(process.cwd(), 'package.json'); + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent); + + packageJson.version = newVersion; + + await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + + console.log(`Updated package.json version to ${newVersion}`); + } catch (error) { + console.error('Error updating package.json:', error); + process.exit(1); + } +} + +/** + * Generate the changelog + * @param {string} newVersion - The new version + * @returns {Promise} The changelog content + */ +async function generateChangelog(newVersion) { + try { + console.log('Generating changelog...'); + + return await generateChangelogFromModule({ + octokit, + nextVersion: newVersion, + release: 'master', // Default value from releaseChangelog.mjs + returnEntry: true, + }); + } catch (error) { + console.error('Error generating changelog:', error); + process.exit(1); + } +} + +/** + * Add the new changelog entry to the CHANGELOG.md file + * @param {string} changelogContent - The changelog content + * @returns {Promise} + */ +async function updateChangelog(changelogContent) { + try { + console.log('Adding changelog entry to CHANGELOG.md...'); + + const changelogPath = path.join(process.cwd(), 'CHANGELOG.md'); + const changelogContent2 = await fs.readFile(changelogPath, 'utf8'); + + // Find the position of the first version entry (currently ## 8.5.1) + const lines = changelogContent2.split('\n'); + const firstVersionLineIndex = lines.findIndex((line) => /^## [0-9]/.test(line)); + + if (firstVersionLineIndex === -1) { + throw new Error('Could not find the first version entry in CHANGELOG.md'); + } + + // Create a new changelog with the new content + const newChangelog = [ + ...lines.slice(0, firstVersionLineIndex), + `${changelogContent}\n`, + ...lines.slice(firstVersionLineIndex), + ].join('\n'); + + await fs.writeFile(changelogPath, newChangelog); + + console.log('Changelog updated. Please review the changes.'); + } catch (error) { + console.error('Error updating changelog:', error); + process.exit(1); + } +} + +/** + * Create the PR body with checklist + * @param {string} newVersion - The new version + * @returns {string} The PR body + */ +function createPrBody(newVersion) { + return `Release version ${newVersion} + +### Prepare the release of the packages + +- [x] Compare the last tag with the branch upon which you want to release +- [x] Clean the generated changelog +- [x] Update the root package.json's version +- [x] Update the versions of the other package.json files +- [x] Open PR with changes and wait for review and green CI +- [ ] Once CI is green and you have enough approvals, send a message on the team-x slack channel announcing a merge freeze +- [ ] Merge PR + +### Release the packages + +- [ ] Checkout the last version of the working branch +- [ ] Run \`pnpm i && pnpm release:build\` +- [ ] Run \`pnpm release:publish\` +- [ ] Run \`pnpm release:tag\` + +### Publish the documentation + +- [ ] Run \`pnpm docs:deploy\` + +### Publish GitHub release + +- [ ] Create a new release on GitHub releases page + +### Announce + +- [ ] Follow the instructions in https://mui-org.notion.site/Releases-7490ef9581b4447ebdbf86b13164272d +`; +} + +/** + * Get all members of the mui/x team from GitHub + * @param {string} [excludeUsername] - Username to exclude from the results (e.g., PR author) + * @returns {Promise} Array of GitHub usernames + */ +async function getTeamMembers(excludeUsername) { + try { + console.log('Fetching members of the mui/x team...'); + + // Get team members + const { data: teams } = await octokit.rest.teams.list({ + org: ORG, + }); + + // Find the x team + const xTeam = teams.find((team) => team.name.toLowerCase() === 'x'); + + if (!xTeam) { + console.warn('Warning: Could not find the mui/x team.'); + return []; + } + + // Get team members + const { data: members } = await octokit.rest.teams.listMembersInOrg({ + org: ORG, + team_slug: xTeam.slug, + }); + + let usernames = members.map((member) => member.login); + + // Filter out the excluded username if provided + if (excludeUsername) { + usernames = usernames.filter((username) => username !== excludeUsername); + console.log(`Filtered out PR author (${excludeUsername}) from team members.`); + } + + console.log(`Found ${usernames.length} members in the mui/x team.`); + + return usernames; + } catch (error) { + if (error.status === 403) { + console.error( + 'Error: You do not have permission to access the mui/x team members.', + 'You need admin permissions on the repo to view teams and team members.', + 'Please add reviewers manually.', + ); + return []; + } + console.error('Error fetching team members:', error.message); + if (error.response) { + console.error(`Status: ${error.response.status}`); + console.error('Response data:', error.response.data); + } + return []; + } +} + +/** + * Assign reviewers to a pull request + * @param {number} prNumber - The PR number + * @param {string[]} reviewers - Array of GitHub usernames to assign as reviewers + * @returns {Promise} Whether the operation was successful + */ +async function assignReviewers(prNumber, reviewers) { + try { + console.log(`Assigning ${reviewers.length} reviewers to PR #${prNumber}...`); + + // Assign reviewers + await octokit.rest.pulls.requestReviewers({ + owner: ORG, + repo: REPO, + pull_number: prNumber, + reviewers, + }); + + console.log('Reviewers assigned successfully.'); + return true; + } catch (error) { + console.error('Error assigning reviewers:', error.message); + if (error.response) { + console.error(`Status: ${error.response.status}`); + console.error('Response data:', error.response.data); + } + return false; + } +} + +/** + * Add labels to a pull request + * @param {number} prNumber - The PR number + * @param {string[]} labels - Array of label names to add to the PR + * @returns {Promise} Whether the operation was successful + */ +async function addLabelsToPR(prNumber, labels) { + try { + console.log(`Adding labels [${labels.join(', ')}] to PR #${prNumber}...`); + + // Add labels to the PR (PRs are treated as issues in the GitHub API) + await octokit.rest.issues.addLabels({ + owner: ORG, + repo: REPO, + issue_number: prNumber, + labels, + }); + + console.log('Labels added successfully.'); + return true; + } catch (error) { + console.error('Error adding labels:', error.message); + if (error.response) { + console.error(`Status: ${error.response.status}`); + console.error('Response data:', error.response.data); + } + return false; + } +} + +/** + * Create a pull request using Octokit + * @param {string} title - The PR title + * @param {string} body - The PR body + * @param {string} head - The branch name + * @param {string} base - The base branch + * @returns {Promise<{ + * url: string, + * number: number + * }>} The URL and number of the created PR + */ +async function createPullRequest(title, body, head, base) { + try { + console.log('Creating PR using Octokit...'); + + // Create the PR + const { data } = await octokit.rest.pulls.create({ + owner: ORG, + repo: REPO, + title, + body, + head, + base, + }); + + console.log(`PR created successfully: ${data.html_url}`); + return { url: data.html_url, number: data.number }; + } catch (error) { + console.error('Error creating PR with Octokit:', error.message); + if (error.response) { + console.error(`Status: ${error.response.status}`); + console.error('Response data:', error.response.data); + } + throw error; + } +} + +/** + * Main function + */ +async function main({ githubToken }) { + try { + // Check if we're in the repository root + try { + await Promise.all([ + fs.access(path.join(process.cwd(), 'package.json')), + fs.access(path.join(process.cwd(), 'CHANGELOG.md')), + ]); + } catch (error) { + console.error('Error: Please run this script from the repository root.'); + process.exit(1); + } + + // If no token is provided, throw an error + if (!githubToken) { + console.error( + 'Unable to authenticate. Make sure you either call the script with `--githubToken $token` or set `process.env.GITHUB_TOKEN`. The token needs `public_repo` permissions.', + ); + process.exit(1); + } + + octokit = new MyOctokit({ + auth: githubToken, + }); + + // Initialize variables + let versionType = ''; + let customVersion = ''; + let calculatedVersion = ''; + + // Parse command line arguments + if (argv.patch) { + versionType = 'patch'; + } else if (argv.minor) { + versionType = 'minor'; + } else if (argv.major) { + versionType = 'major'; + } else if (argv.custom) { + customVersion = argv.custom; + } + + // Always prompt for major version first + const majorVersion = await selectMajorVersion(); + + // If no arguments provided, use interactive menu to select version type + // Initialize prerelease variables (used for alpha/beta versions) + let prereleaseType = ''; + let prereleaseNumber = 0; + + if (!versionType && !customVersion) { + const result = await selectVersionType(majorVersion); + versionType = result.versionType; + calculatedVersion = result.calculatedVersion; + customVersion = result.customVersion; + prereleaseType = result.prereleaseType; + prereleaseNumber = result.prereleaseNumber; + } else { + // Command-line arguments provided, calculate versions from tags + const { success, nextPatch, nextMinor, nextMajor } = + await calculateVersionsFromTags(majorVersion); + + // If a version type was specified, set the calculated version + if (versionType && success) { + if (versionType === 'patch' && nextPatch) { + calculatedVersion = nextPatch; + console.log(`Using calculated patch version: ${calculatedVersion}`); + } else if (versionType === 'minor' && nextMinor) { + calculatedVersion = nextMinor; + console.log(`Using calculated minor version: ${calculatedVersion}`); + } else if (versionType === 'major' && nextMajor) { + calculatedVersion = nextMajor; + console.log(`Using calculated major version: ${calculatedVersion}`); + } + } + } + + // Calculate new version + const newVersion = calculateNewVersion( + versionType, + calculatedVersion, + customVersion, + prereleaseType, + prereleaseNumber, + ); + console.log(`New version: ${newVersion}`); + + // Find the upstream remote + const upstreamRemote = await findMuiXRemote(); + console.log(`Found upstream remote: ${upstreamRemote}`); + + // Determine which branch to update based on the selected major version + const currentMajorVersion = packageVersion.split('.')[0]; + if (majorVersion === currentMajorVersion) { + console.log('Updating the upstream master branch for current major version...'); + await execa('git', ['fetch', upstreamRemote, 'master']); + } else { + console.log(`Updating the upstream v${majorVersion}.x branch...`); + await execa('git', ['fetch', upstreamRemote, `v${majorVersion}.x`]); + } + + // Create a new branch with the new version + const branchName = `release/v${newVersion}-${new Date().toISOString().slice(0, 10)}`; + console.log(`Creating new branch: ${branchName}`); + + // Check for uncommitted changes + await checkUncommittedChanges(); + + // Determine the source branch based on the selected major version + let branchSource; + if (majorVersion === currentMajorVersion) { + branchSource = `${upstreamRemote}/master`; + console.log(`Creating branch from master for current major version: ${branchSource}`); + } else { + branchSource = `${upstreamRemote}/v${majorVersion}.x`; + console.log(`Creating branch from version branch: ${branchSource}`); + } + + await execa('git', ['checkout', '-b', branchName, '--no-track', branchSource]); + + // Update package.json + await updatePackageJson(newVersion); + + // Run lerna version script + console.log('Running lerna version script...'); + await execa( + 'npx', + [ + 'lerna', + 'version', + '--exact', + '--no-changelog', + '--no-push', + '--no-git-tag-version', + '--no-private', + ], + { stdio: 'inherit' }, + ); + + console.log('Version update completed successfully!'); + console.log(`New version: ${newVersion}`); + + // Generate the changelog + const changelogContent = await generateChangelog(newVersion); + + // Add the new changelog entry to the CHANGELOG.md file + await updateChangelog(changelogContent); + + // Wait for user confirmation + await inquirer.prompt([ + { + type: 'confirm', + name: 'continue', + message: 'Press Enter to continue after reviewing the changes, or Ctrl+C to abort...', + default: true, + }, + ]); + + // Commit the changes + console.log('Committing changes...'); + await execa('git', ['add', 'package.json', 'CHANGELOG.md', 'packages/*/package.json']); + await execa('git', ['commit', '-m', `[release] v${newVersion}`]); + + console.log(`Changes committed to branch ${branchName}`); + + // Push the committed changes to fork remote + console.log('Pushing committed changes to fork remote...'); + try { + const forkRemote = await findForkRemote(); + await execa('git', ['push', forkRemote, branchName]); + console.log(`Changes pushed to ${forkRemote}/${branchName}`); + } catch (error) { + console.error('Error pushing to fork remote:', error); + console.error('Falling back to pushing to origin...'); + await execa('git', ['push', 'origin', branchName]); + console.log(`Changes pushed to origin/${branchName}`); + } + + // Create PR body with checklist + const prBody = createPrBody(newVersion); + + // Open a PR + console.log('Opening a PR...'); + try { + // Determine the base branch based on the selected major version + const baseBranch = majorVersion === currentMajorVersion ? 'master' : `v${majorVersion}.x`; + + // Get the origin owner (username or organization) + const forkOwner = await findForkOwner(); + + // Create the PR using Octokit + const { url: prUrl, number: prNumber } = await createPullRequest( + `[release] v${newVersion}`, + prBody, + `${forkOwner}:${branchName}`, + baseBranch, + ); + + console.log(`PR created successfully: ${prUrl}`); + + // Step 1: Apply labels to the PR + // Add 'release' label and a version label in the format 'v8.x' + const versionLabel = `v${majorVersion}.x`; + await addLabelsToPR(prNumber, ['release', versionLabel]); + + // Step 2: Get all members of the 'mui/x' team from GitHub (excluding the PR author) + const teamMembers = await getTeamMembers(forkOwner); + + if (teamMembers.length > 0) { + // Randomly select up to 15 team members as reviewers + const shuffledMembers = [...teamMembers].sort(() => 0.5 - Math.random()); + const selectedReviewers = shuffledMembers.slice(0, Math.min(15, shuffledMembers.length)); + + console.log(`Randomly selected ${selectedReviewers.length} team members as reviewers.`); + + // Assign the selected reviewers to the PR + await assignReviewers(prNumber, selectedReviewers); + } + } catch (error) { + console.error('Failed to create PR with Octokit or assign reviewers.'); + console.error( + `You can manually create a PR with title: [release] v${newVersion} and label: release`, + ); + console.error(`Branch: ${branchName}`); + console.error('Use the following checklist in the PR body:'); + console.error(prBody); + } + + console.log('Release preparation completed!'); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +yargs(hideBin(process.argv)) + .command({ + command: '$0', + description: 'Prepares a release PR for MUI X', + builder: (command) => { + return command.option('githubToken', { + default: process.env.GITHUB_TOKEN, + describe: + 'The personal access token to use for authenticating with GitHub. Needs public_repo permissions.', + type: 'string', + }); + }, + handler: main, + }) + .help() + .strict(true) + .version(false) + .parse(); diff --git a/scripts/releaseChangelog.mjs b/scripts/releaseChangelog.mjs index 1152fceb82377..5a4a6cbbbf5fb 100644 --- a/scripts/releaseChangelog.mjs +++ b/scripts/releaseChangelog.mjs @@ -1,393 +1,50 @@ +#!/usr/bin/env node /* eslint-disable no-restricted-syntax */ -import { Octokit } from '@octokit/rest'; +/** + * This script generates a changelog for MUI X packages. + * + * Features: + * - Fetches commits between two Git references (tags/branches) + * - Categorizes commits based on tags in commit messages and PR labels + * - Generates a changelog with sections for different packages + * - Uses actual versions from package.json files + * - Can return the changelog as a string when --returnEntry is passed + */ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; - -const GIT_ORGANIZATION = 'mui'; -const GIT_REPO = 'mui-x'; +import { Octokit } from '@octokit/rest'; +import { retry } from '@octokit/plugin-retry'; +import { generateChangelog } from './changelogUtils.mjs'; /** - * @param {string} commitMessage - * @returns {string} The tags in lowercases, ordered ascending and comma-separated + * Create a custom Octokit class with retry functionality + * @type {typeof import('@octokit/rest').Octokit} */ -function parseTags(commitMessage) { - const tagMatch = commitMessage.match(/^(\[[\w- ]+\])+/); - if (tagMatch === null) { - return ''; - } - const [tagsWithBracketDelimiter] = tagMatch; - return tagsWithBracketDelimiter - .match(/([\w- ]+)/g) - .map((tag) => { - return tag; - }) - .sort((a, b) => { - return a.localeCompare(b); - }) - .join(','); -} +const MyOctokit = Octokit.plugin(retry); /** - * @param {Octokit.ReposCompareCommitsResponseCommitsItem} commitsItem + * Main function for the CLI + * @param {object} argv - The command line arguments + * @param {string} [argv.release] - Ref which we want to release + * @param {string} [argv.lastRelease] - The release to compare against + * @param {string} [argv.nextVersion] - The version expected to be released + * @param {string} [argv.githubToken] - GitHub token for authentication + * @returns {Promise} The changelog string or null */ -function filterCommit(commitsItem) { - // TODO: Use labels - - // Filter dependency updates - return !commitsItem.commit.message.startsWith('Bump'); -} - -async function findLatestTaggedVersion(octokit) { - // fetch tags from the GitHub API and return the last one - const { data } = await octokit.request(`GET /repos/${GIT_ORGANIZATION}/${GIT_REPO}/tags`); - return data[0].name.trim(); -} - -function resolvePackagesByLabels(labels) { - const resolvedPackages = []; - labels.forEach((label) => { - switch (label.name) { - case 'component: data grid': - resolvedPackages.push('DataGrid'); - break; - case 'component: pickers': - resolvedPackages.push('pickers'); - break; - default: - break; - } - }); - return resolvedPackages; -} - async function main(argv) { - const { githubToken, lastRelease: lastReleaseInput, release, nextVersion } = argv; + const { githubToken, ...rest } = argv; if (!githubToken) { throw new TypeError( 'Unable to authenticate. Make sure you either call the script with `--githubToken $token` or set `process.env.GITHUB_TOKEN`. The token needs `public_repo` permissions.', ); } - // Initialize the API client - const octokit = new Octokit({ - auth: githubToken, - }); - - // fetch the last tag and chose the one to use for the release - const latestTaggedVersion = await findLatestTaggedVersion(octokit); - const lastRelease = lastReleaseInput !== undefined ? lastReleaseInput : latestTaggedVersion; - if (lastRelease !== latestTaggedVersion) { - console.warn( - `Creating changelog for ${lastRelease}..${release} when latest tagged version is '${latestTaggedVersion}'.`, - ); - } - - // Now We will fetch all the commits between the chosen tag and release branch - /** - * @type {AsyncIterableIterator>} - */ - const timeline = octokit.paginate.iterator( - octokit.repos.compareCommits.endpoint.merge({ - owner: GIT_ORGANIZATION, - repo: GIT_REPO, - base: lastRelease, - head: release, - }), - ); - - /** - * @type {Octokit.ReposCompareCommitsResponseCommitsItem[]} - */ - const commitsItems = []; - for await (const response of timeline) { - const { data: compareCommits } = response; - commitsItems.push(...compareCommits.commits.filter(filterCommit)); - } - - // Fetch all the pull Request and check if there is a section named changelog - - const changeLogMessages = []; - const prsLabelsMap = {}; - const community = { - firstTimers: new Set(), - contributors: new Set(), - team: new Set(), - }; - await Promise.all( - commitsItems.map(async (commitsItem) => { - const searchPullRequestId = commitsItem.commit.message.match(/\(#([0-9]+)\)/); - if (!searchPullRequestId || !searchPullRequestId[1]) { - return; - } - - const { - data: { - body: bodyMessage, - labels, - author_association, - user: { login }, - }, - } = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { - owner: GIT_ORGANIZATION, - repo: GIT_REPO, - pull_number: Number(searchPullRequestId[1]), - }); - - switch (author_association) { - case 'CONTRIBUTOR': - community.contributors.add(`@${login}`); - break; - case 'FIRST_TIMER': - community.firstTimers.add(`@${login}`); - break; - case 'MEMBER': - community.team.add(`@${login}`); - break; - default: - } - - prsLabelsMap[commitsItem.sha] = labels; - - if (!bodyMessage) { - return; - } - - const changelogMotif = '# changelog'; - const changelogIndex = bodyMessage.toLowerCase().indexOf(changelogMotif); - if (changelogIndex >= 0) { - changeLogMessages.push( - `From https://github.com/${GIT_ORGANIZATION}/${GIT_REPO}/pull/${ - searchPullRequestId[1] - }\n${bodyMessage.slice(changelogIndex + changelogMotif.length)}`, - ); - } - }), - ); - - // Get all the authors of the release - const authors = Array.from( - new Set( - commitsItems.map((commitsItem) => { - return commitsItem.author.login; - }), - ), - ); - - // Dispatch commits in different sections - const dataGridCommits = []; - const dataGridProCommits = []; - const dataGridPremiumCommits = []; - const pickersCommits = []; - const pickersProCommits = []; - const chartsCommits = []; - const chartsProCommits = []; - const treeViewCommits = []; - const treeViewProCommits = []; - const coreCommits = []; - const docsCommits = []; - const otherCommits = []; - const codemodCommits = []; - commitsItems.forEach((commitItem) => { - const tag = parseTags(commitItem.commit.message); - // for now we use only one parsed tag - const firstTag = tag.split(',')[0]; - switch (firstTag) { - case 'DataGrid': - case 'data grid': - dataGridCommits.push(commitItem); - break; - case 'DataGridPro': - dataGridProCommits.push(commitItem); - break; - case 'DataGridPremium': - dataGridPremiumCommits.push(commitItem); - break; - case 'DatePicker': - case 'TimePicker': - case 'DateTimePicker': - case 'pickers': - case 'fields': - pickersCommits.push(commitItem); - break; - case 'DateRangePicker': - case 'DateTimeRangePicker': - pickersProCommits.push(commitItem); - break; - case 'charts-pro': - chartsProCommits.push(commitItem); - break; - case 'charts': - chartsCommits.push(commitItem); - break; - case 'TreeView': - case 'RichTreeView': - case 'tree view': - treeViewCommits.push(commitItem); - break; - case 'RichTreeViewPro': - case 'tree view pro': - treeViewProCommits.push(commitItem); - break; - case 'docs': - docsCommits.push(commitItem); - break; - case 'core': - coreCommits.push(commitItem); - break; - case 'codemod': - codemodCommits.push(commitItem); - break; - case 'l10n': - case '118n': { - const prLabels = prsLabelsMap[commitItem.sha]; - const resolvedPackages = resolvePackagesByLabels(prLabels); - if (resolvedPackages.length > 0) { - resolvedPackages.forEach((resolvedPackage) => { - switch (resolvedPackage) { - case 'DataGrid': - dataGridCommits.push(commitItem); - break; - case 'pickers': - pickersCommits.push(commitItem); - break; - default: - coreCommits.push(commitItem); - break; - } - }); - } - break; - } - default: - otherCommits.push(commitItem); - break; - } - }); - - // Helper to print a list of commits in a section of the changelog - const logChangelogSection = (commitsList, header) => { - if (commitsList.length === 0) { - return ''; - } - - const sortedCommits = commitsList.sort((a, b) => { - const aTags = parseTags(a.commit.message); - const bTags = parseTags(b.commit.message); - if (aTags === bTags) { - return a.commit.message < b.commit.message ? -1 : 1; - } - return aTags.localeCompare(bTags); - }); - return `${header ? `\n${header}\n\n` : ''}${sortedCommits - .sort() - .map( - (commitItem) => `- ${commitItem.commit.message.split('\n')[0]} @${commitItem.author.login}`, - ) - .join('\n')}`; - }; - - const nowFormatted = new Date().toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', + const octokit = new MyOctokit({ + auth: githubToken, }); - const logCommunitySection = () => { - // TODO: separate first timers and regular contributors - const contributors = [ - ...Array.from(community.contributors), - ...Array.from(community.firstTimers), - ].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - if (contributors.length === 0) { - return ''; - } - - return `Special thanks go out to the community members for their valuable contributions:\n${contributors.join(', ')}.`; - }; - - const logTeamSection = () => { - return `Following are all team members who have contributed to this release:\n${Array.from( - community.team, - ) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) - .join(', ')}.`; - }; - - const changelog = ` -## __VERSION__ - -_${nowFormatted}_ - -We'd like to offer a big thanks to the ${ - authors.length - } contributors who made this release possible. Here are some highlights ✨: - -TODO INSERT HIGHLIGHTS -${changeLogMessages.length > 0 ? '\n\n' : ''}${changeLogMessages.join('\n')} -${logCommunitySection()} -${logTeamSection()} - - - -### Data Grid - -#### \`@mui/x-data-grid@__VERSION__\` - -${logChangelogSection(dataGridCommits) || 'Internal changes.'} - -#### \`@mui/x-data-grid-pro@__VERSION__\` [![pro](https://mui.com/r/x-pro-svg)](https://mui.com/r/x-pro-svg-link 'Pro plan') - -Same changes as in \`@mui/x-data-grid@__VERSION__\`${ - dataGridProCommits.length > 0 ? ', plus:\n' : '.' - } -${logChangelogSection(dataGridProCommits)}${dataGridProCommits.length > 0 ? '\n' : ''} -#### \`@mui/x-data-grid-premium@__VERSION__\` [![premium](https://mui.com/r/x-premium-svg)](https://mui.com/r/x-premium-svg-link 'Premium plan') - -Same changes as in \`@mui/x-data-grid-pro@__VERSION__\`${ - dataGridPremiumCommits.length > 0 ? ', plus:\n' : '.' - } -${logChangelogSection(dataGridPremiumCommits)}${dataGridPremiumCommits.length > 0 ? '\n' : ''} -### Date and Time Pickers - -#### \`@mui/x-date-pickers@__VERSION__\` - -${logChangelogSection(pickersCommits) || 'Internal changes.'} - -#### \`@mui/x-date-pickers-pro@__VERSION__\` [![pro](https://mui.com/r/x-pro-svg)](https://mui.com/r/x-pro-svg-link 'Pro plan') - -Same changes as in \`@mui/x-date-pickers@__VERSION__\`${ - pickersProCommits.length > 0 ? ', plus:\n' : '.' - } -${logChangelogSection(pickersProCommits)}${pickersProCommits.length > 0 ? '\n' : ''} -### Charts - -#### \`@mui/x-charts@__VERSION__\` - -${logChangelogSection(chartsCommits) || 'Internal changes.'} - -#### \`@mui/x-charts-pro@__VERSION__\` [![pro](https://mui.com/r/x-pro-svg)](https://mui.com/r/x-pro-svg-link 'Pro plan') - -Same changes as in \`@mui/x-charts@__VERSION__\`${chartsProCommits.length > 0 ? ', plus:\n' : '.'} -${logChangelogSection(chartsProCommits)}${chartsProCommits.length > 0 ? '\n' : ''} -### Tree View - -#### \`@mui/x-tree-view@__VERSION__\` -${logChangelogSection(treeViewProCommits) || 'Internal changes.'} - -#### \`@mui/x-tree-view-pro@__VERSION__\` [![pro](https://mui.com/r/x-pro-svg)](https://mui.com/r/x-pro-svg-link 'Pro plan') - -Same changes as in \`@mui/x-tree-view@__VERSION__\`${treeViewProCommits.length > 0 ? ', plus:\n' : '.'} -${logChangelogSection(treeViewProCommits)}${treeViewProCommits.length > 0 ? '\n' : ''} -${logChangelogSection(codemodCommits, `### \`@mui/x-codemod@__VERSION__\``)} -${logChangelogSection(docsCommits, '### Docs')} -${logChangelogSection(coreCommits, '### Core')} -${logChangelogSection(otherCommits, '')} - -`; - - // eslint-disable-next-line no-console -- output of this script - console.log(nextVersion ? changelog.replace(/__VERSION__/g, nextVersion) : changelog); + return generateChangelog({ ...rest, octokit }); } yargs(hideBin(process.argv))