|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +const { Octokit } = require('@octokit/rest'); |
| 4 | +const chalk = require('chalk').default; |
| 5 | +const program = require('commander'); |
| 6 | +const fs = require('fs'); |
| 7 | +const path = require('path'); |
| 8 | +const os = require('os'); |
| 9 | + |
| 10 | +const evmConfig = require('./evm-config'); |
| 11 | +const { spawnSync } = require('./utils/depot-tools'); |
| 12 | +const { getGerritPatchDetailsFromURL } = require('./utils/gerrit'); |
| 13 | +const { getGitHubAuthToken } = require('./utils/github-auth'); |
| 14 | +const { color, fatal } = require('./utils/logging'); |
| 15 | + |
| 16 | +const ELECTRON_REPO_DATA = { |
| 17 | + owner: 'electron', |
| 18 | + repo: 'electron', |
| 19 | +}; |
| 20 | +const DEPS_REGEX = new RegExp(`chromium_version':\n +'(.+?)',`, 'm'); |
| 21 | +const CL_REGEX = /https:\/\/chromium-review\.googlesource\.com\/c\/chromium\/src\/\+\/(\d+)/; |
| 22 | + |
| 23 | +async function getChromiumVersion(octokit, ref) { |
| 24 | + const { data } = await octokit.repos.getContent({ |
| 25 | + ...ELECTRON_REPO_DATA, |
| 26 | + path: 'DEPS', |
| 27 | + ref, |
| 28 | + }); |
| 29 | + |
| 30 | + if (!data.content) { |
| 31 | + fatal('Could not read content of PR'); |
| 32 | + return; |
| 33 | + } |
| 34 | + |
| 35 | + const [, version] = DEPS_REGEX.exec(Buffer.from(data.content, 'base64').toString('utf8')); |
| 36 | + |
| 37 | + return version; |
| 38 | +} |
| 39 | + |
| 40 | +// Copied from https://github.com/electron/electron/blob/3a3595f2af59cb08fb09e3e2e4b7cdf713db2b27/script/release/notes/notes.ts#L605-L623 |
| 41 | +const compareChromiumVersions = (v1, v2) => { |
| 42 | + const [split1, split2] = [v1.split('.'), v2.split('.')]; |
| 43 | + |
| 44 | + if (split1.length !== split2.length) { |
| 45 | + throw new Error( |
| 46 | + `Expected version strings to have same number of sections: ${split1} and ${split2}`, |
| 47 | + ); |
| 48 | + } |
| 49 | + for (let i = 0; i < split1.length; i++) { |
| 50 | + const p1 = parseInt(split1[i], 10); |
| 51 | + const p2 = parseInt(split2[i], 10); |
| 52 | + |
| 53 | + if (p1 > p2) return 1; |
| 54 | + else if (p1 < p2) return -1; |
| 55 | + // Continue checking the value if this portion is equal |
| 56 | + } |
| 57 | + |
| 58 | + return 0; |
| 59 | +}; |
| 60 | + |
| 61 | +program |
| 62 | + .arguments('[roll-pr] [chromium-version]') |
| 63 | + .description('Attempts to reconstruct an intermediate Chromium version from a roll PR') |
| 64 | + .option('--sort', 'Sort cherry-picked commits by CL merge time', false) |
| 65 | + .option( |
| 66 | + '--merge-strategy-option', |
| 67 | + 'Git merge strategy option to use when cherry-picking', |
| 68 | + 'theirs', |
| 69 | + ) |
| 70 | + .action(async (prNumberStr, chromiumVersionStr, options) => { |
| 71 | + const prNumber = parseInt(prNumberStr, 10); |
| 72 | + if (isNaN(prNumber) || `${prNumber}` !== prNumberStr) { |
| 73 | + fatal(`rcv requires a PR number, "${prNumberStr}" was provided`); |
| 74 | + return; |
| 75 | + } |
| 76 | + |
| 77 | + if (!chromiumVersionStr) { |
| 78 | + fatal('rcv requires a Chromium version, none was provided'); |
| 79 | + return; |
| 80 | + } |
| 81 | + |
| 82 | + const octokit = new Octokit({ |
| 83 | + auth: await getGitHubAuthToken(['repo']), |
| 84 | + }); |
| 85 | + const { data: pr } = await octokit.pulls.get({ |
| 86 | + ...ELECTRON_REPO_DATA, |
| 87 | + pull_number: prNumber, |
| 88 | + }); |
| 89 | + if (!pr.merge_commit_sha) { |
| 90 | + fatal('No merge SHA available on PR'); |
| 91 | + return; |
| 92 | + } |
| 93 | + |
| 94 | + const initialVersion = await getChromiumVersion(octokit, pr.base.sha); |
| 95 | + const newVersion = await getChromiumVersion(octokit, pr.head.sha); |
| 96 | + |
| 97 | + if (initialVersion === newVersion) { |
| 98 | + fatal('Does not look like a Chromium roll PR'); |
| 99 | + return; |
| 100 | + } |
| 101 | + |
| 102 | + if ( |
| 103 | + compareChromiumVersions(chromiumVersionStr, initialVersion) < 0 || |
| 104 | + compareChromiumVersions(chromiumVersionStr, newVersion) > 0 |
| 105 | + ) { |
| 106 | + fatal( |
| 107 | + `Chromium version ${chalk.blueBright(chromiumVersionStr)} is not between ${chalk.blueBright(initialVersion)} and ${chalk.blueBright(newVersion)}`, |
| 108 | + ); |
| 109 | + return; |
| 110 | + } |
| 111 | + |
| 112 | + // TODO(dsanders11) - confirm chromiumVersionStr is a tagged Chromium version |
| 113 | + |
| 114 | + // TODO(dsanders11): pull list of possible Chromium versions for the |
| 115 | + // PR in question and make the version argument optional, and show the |
| 116 | + // user a list of versions to choose from if they don't provide one |
| 117 | + /** |
| 118 | + const { version: chromiumVersion } = await inquirer.prompt([ |
| 119 | + { |
| 120 | + type: 'list', |
| 121 | + name: 'version', |
| 122 | + message: 'Which Chromium version do you want to reconstruct?', |
| 123 | + choices: chromiumVersions, |
| 124 | + }, |
| 125 | + ]); |
| 126 | + */ |
| 127 | + |
| 128 | + const config = evmConfig.current(); |
| 129 | + const gitOpts = { |
| 130 | + cwd: path.resolve(config.root, 'src', 'electron'), |
| 131 | + stdio: 'pipe', |
| 132 | + }; |
| 133 | + const result = spawnSync(config, 'git', ['status', '--porcelain'], gitOpts); |
| 134 | + if (result.status !== 0 || result.stdout.toString().trim().length !== 0) { |
| 135 | + fatal( |
| 136 | + "Your current git working directory is not clean, we won't erase your local changes. Clean it up and try again", |
| 137 | + ); |
| 138 | + return; |
| 139 | + } |
| 140 | + |
| 141 | + // Checkout the parent of the merge commit if it was merged, else the base SHA |
| 142 | + let targetSha = pr.base.sha; |
| 143 | + |
| 144 | + if (pr.merged) { |
| 145 | + const { data: mergeCommit } = await octokit.git.getCommit({ |
| 146 | + ...ELECTRON_REPO_DATA, |
| 147 | + commit_sha: pr.merge_commit_sha, |
| 148 | + }); |
| 149 | + if (mergeCommit.parents.length !== 1) { |
| 150 | + fatal('Expected merge commit to have one parent'); |
| 151 | + return; |
| 152 | + } |
| 153 | + targetSha = mergeCommit.parents[0].sha; |
| 154 | + } |
| 155 | + |
| 156 | + const ensureShaLocal = spawnSync(config, 'git', ['fetch', 'origin', targetSha], gitOpts); |
| 157 | + if (ensureShaLocal.status !== 0) { |
| 158 | + fatal('Failed to fetch upstream base'); |
| 159 | + return; |
| 160 | + } |
| 161 | + |
| 162 | + const checkoutResult = spawnSync(config, 'git', ['checkout', targetSha], gitOpts); |
| 163 | + if (checkoutResult.status !== 0) { |
| 164 | + fatal('Failed to checkout base commit'); |
| 165 | + return; |
| 166 | + } |
| 167 | + |
| 168 | + const rcvBranch = `rcv/pr/${prNumber}/version/${chromiumVersionStr}`; |
| 169 | + spawnSync(config, 'git', ['branch', '-D', rcvBranch], gitOpts); |
| 170 | + const checkoutBranchResult = spawnSync(config, 'git', ['checkout', '-b', rcvBranch], gitOpts); |
| 171 | + if (checkoutBranchResult.status !== 0) { |
| 172 | + fatal(`Failed to checkout new branch "${rcvBranch}"`); |
| 173 | + return; |
| 174 | + } |
| 175 | + |
| 176 | + const yarnInstallResult = spawnSync(config, 'yarn', ['install'], gitOpts); |
| 177 | + if (yarnInstallResult.status !== 0) { |
| 178 | + fatal(`Failed to do "yarn install" on new branch`); |
| 179 | + return; |
| 180 | + } |
| 181 | + |
| 182 | + // Update the Chromium version in DEPS |
| 183 | + const regexToReplace = new RegExp(`(chromium_version':\n +').+?',`, 'gm'); |
| 184 | + const content = await fs.promises.readFile(path.resolve(gitOpts.cwd, 'DEPS'), 'utf8'); |
| 185 | + const newContent = content.replace(regexToReplace, `$1${chromiumVersionStr}',`); |
| 186 | + await fs.promises.writeFile(path.resolve(gitOpts.cwd, 'DEPS'), newContent, 'utf8'); |
| 187 | + |
| 188 | + // Make a commit with this change |
| 189 | + const addResult = spawnSync(config, 'git', ['add', 'DEPS'], gitOpts); |
| 190 | + if (addResult.status !== 0) { |
| 191 | + fatal('Failed to add DEPS file for commit'); |
| 192 | + return; |
| 193 | + } |
| 194 | + |
| 195 | + const author = 'Electron Bot <[email protected]>'; |
| 196 | + const message = `chore: bump chromium to ${chromiumVersionStr}`; |
| 197 | + const commitResult = spawnSync( |
| 198 | + config, |
| 199 | + 'git', |
| 200 | + [ |
| 201 | + 'commit', |
| 202 | + '--no-verify', // There's a bug on Windows that creates incorrect changes |
| 203 | + '--author', |
| 204 | + os.platform() === 'win32' ? `"${author}"` : author, |
| 205 | + '-m', |
| 206 | + os.platform() === 'win32' ? `"${message}"` : message, |
| 207 | + ], |
| 208 | + gitOpts, |
| 209 | + ); |
| 210 | + if (commitResult.status !== 0) { |
| 211 | + fatal('Failed to commit DEPS file change'); |
| 212 | + return; |
| 213 | + } |
| 214 | + |
| 215 | + const { data: commits } = await octokit.pulls.listCommits({ |
| 216 | + ...ELECTRON_REPO_DATA, |
| 217 | + pull_number: prNumber, |
| 218 | + }); |
| 219 | + |
| 220 | + const commitsToCherryPick = []; |
| 221 | + |
| 222 | + for (const commit of commits) { |
| 223 | + const shortSha = commit.sha.substring(0, 7); |
| 224 | + const message = commit.commit.message.split('\n')[0]; |
| 225 | + const clMatch = CL_REGEX.exec(commit.commit.message); |
| 226 | + |
| 227 | + if (clMatch) { |
| 228 | + const parsedUrl = new URL(clMatch[0]); |
| 229 | + const { shortCommit: chromiumShortSha } = await getGerritPatchDetailsFromURL(parsedUrl); |
| 230 | + const { commits: chromiumCommits } = await fetch( |
| 231 | + `https://chromiumdash.appspot.com/fetch_commits?commit=${chromiumShortSha}`, |
| 232 | + ).then((resp) => resp.json()); |
| 233 | + if (chromiumCommits.length !== 1) { |
| 234 | + fatal(`Expected to find exactly one commit for SHA "${chromiumShortSha}"`); |
| 235 | + return; |
| 236 | + } |
| 237 | + // Grab the earliest Chromium version the CL was released in, and the merge time |
| 238 | + const { earliest, time } = chromiumCommits[0]; |
| 239 | + |
| 240 | + // Only cherry pick the commit if the earliest version is within target version |
| 241 | + if (compareChromiumVersions(earliest, chromiumVersionStr) <= 0) { |
| 242 | + console.log( |
| 243 | + `${color.success} Cherry-picking CL commit: ${chalk.yellow(shortSha)} ${message} (${chalk.greenBright(earliest)})`, |
| 244 | + ); |
| 245 | + commitsToCherryPick.push({ sha: commit.sha, chromiumVersion: earliest, mergeTime: time }); |
| 246 | + } else { |
| 247 | + console.info( |
| 248 | + `${color.info} Skipping CL commit: ${chalk.yellow(shortSha)} ${message} (${chalk.greenBright(earliest)})`, |
| 249 | + ); |
| 250 | + } |
| 251 | + } else { |
| 252 | + console.info(`${color.info} Skipping non-CL commit: ${chalk.yellow(shortSha)} ${message}`); |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + // Reorder the commits by the merge time of their CL |
| 257 | + if (options.sort) { |
| 258 | + commitsToCherryPick.sort((a, b) => a.mergeTime - b.mergeTime); |
| 259 | + } |
| 260 | + |
| 261 | + for (const commit of commitsToCherryPick) { |
| 262 | + const ensureCommitLocal = spawnSync(config, 'git', ['fetch', 'origin', commit.sha], gitOpts); |
| 263 | + if (ensureCommitLocal.status !== 0) { |
| 264 | + fatal('Failed to fetch commit to cherry-pick'); |
| 265 | + return; |
| 266 | + } |
| 267 | + |
| 268 | + const cherryPickResult = spawnSync( |
| 269 | + config, |
| 270 | + 'git', |
| 271 | + [ |
| 272 | + 'cherry-pick', |
| 273 | + '--allow-empty', |
| 274 | + `--strategy-option=${options.mergeStrategyOption}`, |
| 275 | + commit.sha, |
| 276 | + ], |
| 277 | + gitOpts, |
| 278 | + ); |
| 279 | + if (cherryPickResult.status !== 0) { |
| 280 | + fatal(`Failed to cherry-pick commit "${commit.sha}"`); |
| 281 | + return; |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + // TODO - update filenames and commit |
| 286 | + }); |
| 287 | + |
| 288 | +program.parse(process.argv); |
0 commit comments