|
| 1 | +import os from 'node:os'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { getMetadata } from '../components/metadata.js'; |
| 4 | + |
| 5 | +import { |
| 6 | + runAsync, runSync, forceRunAsync |
| 7 | +} from './run.js'; |
| 8 | +import { writeFile } from './file.js'; |
| 9 | +import { |
| 10 | + shortSha, getEditor |
| 11 | +} from './utils.js'; |
| 12 | +import { getNcuDir } from './config.js'; |
| 13 | + |
| 14 | +const LINT_RESULTS = { |
| 15 | + SKIPPED: 'skipped', |
| 16 | + FAILED: 'failed', |
| 17 | + SUCCESS: 'success' |
| 18 | +}; |
| 19 | + |
| 20 | +export default class CheckPick { |
| 21 | + constructor(prid, dir, cli, { |
| 22 | + owner, |
| 23 | + repo, |
| 24 | + lint, |
| 25 | + includeCVE |
| 26 | + } = {}) { |
| 27 | + this.prid = prid; |
| 28 | + this.cli = cli; |
| 29 | + this.dir = dir; |
| 30 | + this.options = { owner, repo, lint, includeCVE }; |
| 31 | + } |
| 32 | + |
| 33 | + get includeCVE() { |
| 34 | + return this.options.includeCVE ?? false; |
| 35 | + } |
| 36 | + |
| 37 | + get owner() { |
| 38 | + return this.options.owner || 'nodejs'; |
| 39 | + } |
| 40 | + |
| 41 | + get repo() { |
| 42 | + return this.options.repo || 'node'; |
| 43 | + } |
| 44 | + |
| 45 | + get lint() { |
| 46 | + return this.options.lint; |
| 47 | + } |
| 48 | + |
| 49 | + getUpstreamHead() { |
| 50 | + const { upstream, branch } = this; |
| 51 | + return runSync('git', ['rev-parse', `${upstream}/${branch}`]).trim(); |
| 52 | + } |
| 53 | + |
| 54 | + getCurrentRev() { |
| 55 | + return runSync('git', ['rev-parse', 'HEAD']).trim(); |
| 56 | + } |
| 57 | + |
| 58 | + getStrayCommits(verbose) { |
| 59 | + const { upstream, branch } = this; |
| 60 | + const ref = `${upstream}/${branch}...HEAD`; |
| 61 | + const gitCmd = verbose |
| 62 | + ? ['log', '--oneline', '--reverse', ref] |
| 63 | + : ['rev-list', '--reverse', ref]; |
| 64 | + const revs = runSync('git', gitCmd).trim(); |
| 65 | + return revs ? revs.split('\n') : []; |
| 66 | + } |
| 67 | + |
| 68 | + get ncuDir() { |
| 69 | + return getNcuDir(this.dir); |
| 70 | + } |
| 71 | + |
| 72 | + get pullDir() { |
| 73 | + return path.join(this.ncuDir, `${this.prid}`); |
| 74 | + } |
| 75 | + |
| 76 | + getMessagePath(rev) { |
| 77 | + return path.join(this.pullDir, `${shortSha(rev)}.COMMIT_EDITMSG`); |
| 78 | + } |
| 79 | + |
| 80 | + saveMessage(rev, message) { |
| 81 | + const file = this.getMessagePath(rev); |
| 82 | + writeFile(file, message); |
| 83 | + return file; |
| 84 | + } |
| 85 | + |
| 86 | + async start() { |
| 87 | + const { cli } = this; |
| 88 | + |
| 89 | + const metadata = await getMetadata({ |
| 90 | + prid: this.prid, |
| 91 | + owner: this.owner, |
| 92 | + repo: this.repo |
| 93 | + }, false, cli); |
| 94 | + const expectedCommitShas = |
| 95 | + metadata.data.commits.map(({ commit }) => commit.oid); |
| 96 | + |
| 97 | + const amend = await cli.prompt( |
| 98 | + 'Would you like to amend this PR to the proposal?', |
| 99 | + { default: true } |
| 100 | + ); |
| 101 | + |
| 102 | + if (!amend) { |
| 103 | + return true; |
| 104 | + } |
| 105 | + |
| 106 | + try { |
| 107 | + const commitInfo = await this.downloadAndPatch(expectedCommitShas); |
| 108 | + const cleanLint = await this.validateLint(); |
| 109 | + if (cleanLint === LINT_RESULTS.FAILED) { |
| 110 | + cli.error('Patch still contains lint errors. ' + |
| 111 | + 'Please fix manually before proceeding'); |
| 112 | + return false; |
| 113 | + } else if (cleanLint === LINT_RESULTS.SUCCESS) { |
| 114 | + cli.ok('Lint passed cleanly'); |
| 115 | + } |
| 116 | + return this.amend(metadata.metadata, commitInfo); |
| 117 | + } catch (e) { |
| 118 | + cli.error(e.message); |
| 119 | + return false; |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + async downloadAndPatch(expectedCommitShas) { |
| 124 | + const { cli, repo, owner, prid } = this; |
| 125 | + |
| 126 | + cli.startSpinner(`Downloading patch for ${prid}`); |
| 127 | + // fetch via ssh to handle private repo |
| 128 | + await runAsync('git', [ |
| 129 | + 'fetch', `[email protected]:${owner}/${repo}.git`, |
| 130 | + `refs/pull/${prid}/merge`]); |
| 131 | + // We fetched the commit that would result if we used `git merge`. |
| 132 | + // ^1 and ^2 refer to the PR base and the PR head, respectively. |
| 133 | + const [base, head] = await runAsync('git', |
| 134 | + ['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2'], |
| 135 | + { captureStdout: 'lines' }); |
| 136 | + const commitShas = await runAsync('git', |
| 137 | + ['rev-list', `${base}..${head}`], |
| 138 | + { captureStdout: 'lines' }); |
| 139 | + cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`); |
| 140 | + cli.separator(); |
| 141 | + |
| 142 | + const mismatchedCommits = [ |
| 143 | + ...commitShas.filter((sha) => !expectedCommitShas.includes(sha)) |
| 144 | + .map((sha) => `Unexpected commit ${sha}`), |
| 145 | + ...expectedCommitShas.filter((sha) => !commitShas.includes(sha)) |
| 146 | + .map((sha) => `Missing commit ${sha}`) |
| 147 | + ].join('\n'); |
| 148 | + if (mismatchedCommits.length > 0) { |
| 149 | + throw new Error(`Mismatched commits:\n${mismatchedCommits}`); |
| 150 | + } |
| 151 | + |
| 152 | + const commitInfo = { base, head, shas: commitShas }; |
| 153 | + |
| 154 | + try { |
| 155 | + await forceRunAsync('git', ['cherry-pick', `${base}..${head}`], { |
| 156 | + ignoreFailure: false |
| 157 | + }); |
| 158 | + } catch (ex) { |
| 159 | + await forceRunAsync('git', ['cherry-pick', '--abort']); |
| 160 | + throw new Error('Failed to apply patches'); |
| 161 | + } |
| 162 | + |
| 163 | + cli.ok('Patches applied'); |
| 164 | + return commitInfo; |
| 165 | + } |
| 166 | + |
| 167 | + async validateLint() { |
| 168 | + // The linter is currently only run on non-Windows platforms. |
| 169 | + if (os.platform() === 'win32') { |
| 170 | + return LINT_RESULTS.SKIPPED; |
| 171 | + } |
| 172 | + |
| 173 | + if (!this.lint) { |
| 174 | + return LINT_RESULTS.SKIPPED; |
| 175 | + } |
| 176 | + |
| 177 | + try { |
| 178 | + await runAsync('make', ['lint']); |
| 179 | + return LINT_RESULTS.SUCCESS; |
| 180 | + } catch { |
| 181 | + return LINT_RESULTS.FAILED; |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + async amend(metadata, commitInfo) { |
| 186 | + const { cli } = this; |
| 187 | + const subjects = await runAsync('git', |
| 188 | + ['log', '--pretty=format:%s', `${commitInfo.base}..${commitInfo.head}`], |
| 189 | + { captureStdout: 'lines' }); |
| 190 | + |
| 191 | + if (commitInfo.shas.length !== 1) { |
| 192 | + const fixupAll = await cli.prompt( |
| 193 | + `There are ${subjects.length} commits in the PR. ` + |
| 194 | + 'Would you like to fixup everything into first commit?'); |
| 195 | + if (!fixupAll) { |
| 196 | + // TODO: add this support? |
| 197 | + throw new Error(`There are ${subjects.length} commits in the PR ` + |
| 198 | + 'and the ammend were not able to succeed'); |
| 199 | + } |
| 200 | + await runAsync('git', ['reset', '--soft', `HEAD~${subjects.length - 1}`]); |
| 201 | + await runAsync('git', ['commit', '--amend', '--no-edit']); |
| 202 | + } |
| 203 | + |
| 204 | + return this._amend(metadata); |
| 205 | + } |
| 206 | + |
| 207 | + async _amend(metadataStr) { |
| 208 | + const { cli } = this; |
| 209 | + |
| 210 | + const rev = this.getCurrentRev(); |
| 211 | + const original = runSync('git', [ |
| 212 | + 'show', 'HEAD', '-s', '--format=%B' |
| 213 | + ]).trim(); |
| 214 | + // git has very specific rules about what is a trailer and what is not. |
| 215 | + // Instead of trying to implement those ourselves, let git parse the |
| 216 | + // original commit message and see if it outputs any trailers. |
| 217 | + const originalHasTrailers = runSync('git', [ |
| 218 | + 'interpret-trailers', '--parse', '--no-divider' |
| 219 | + ], { |
| 220 | + input: `${original}\n` |
| 221 | + }).trim().length !== 0; |
| 222 | + const metadata = metadataStr.trim().split('\n'); |
| 223 | + const amended = original.split('\n'); |
| 224 | + |
| 225 | + // If the original commit message already contains trailers (such as |
| 226 | + // "Co-authored-by"), we simply add our own metadata after those. Otherwise, |
| 227 | + // we have to add an empty line so that git recognizes our own metadata as |
| 228 | + // trailers in the amended commit message. |
| 229 | + if (!originalHasTrailers) { |
| 230 | + amended.push(''); |
| 231 | + } |
| 232 | + |
| 233 | + const BACKPORT_RE = /BACKPORT-PR-URL\s*:\s*(\S+)/i; |
| 234 | + const PR_RE = /PR-URL\s*:\s*(\S+)/i; |
| 235 | + const REVIEW_RE = /Reviewed-By\s*:\s*(\S+)/i; |
| 236 | + const CVE_RE = /CVE-ID\s*:\s*(\S+)/i; |
| 237 | + |
| 238 | + let containCVETrailer = false; |
| 239 | + for (const line of metadata) { |
| 240 | + if (line.length !== 0 && original.includes(line)) { |
| 241 | + if (line.match(CVE_RE)) { |
| 242 | + containCVETrailer = true; |
| 243 | + } |
| 244 | + if (originalHasTrailers) { |
| 245 | + cli.warn(`Found ${line}, skipping..`); |
| 246 | + } else { |
| 247 | + throw new Error( |
| 248 | + 'Git found no trailers in the original commit message, ' + |
| 249 | + `but '${line}' is present and should be a trailer.`); |
| 250 | + } |
| 251 | + } else { |
| 252 | + if (line.match(BACKPORT_RE)) { |
| 253 | + let prIndex = amended.findIndex(datum => datum.match(PR_RE)); |
| 254 | + if (prIndex === -1) { |
| 255 | + prIndex = amended.findIndex(datum => datum.match(REVIEW_RE)) - 1; |
| 256 | + } |
| 257 | + amended.splice(prIndex + 1, 0, line); |
| 258 | + } else { |
| 259 | + amended.push(line); |
| 260 | + } |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + if (!containCVETrailer && this.includeCVE) { |
| 265 | + const cveID = await cli.prompt( |
| 266 | + 'Git found no CVE-ID trailer in the original commit message. ' + |
| 267 | + 'Please, provide the CVE-ID', |
| 268 | + { questionType: 'input', defaultAnswer: 'CVE-2023-XXXXX' } |
| 269 | + ); |
| 270 | + amended.push('CVE-ID: ' + cveID); |
| 271 | + } |
| 272 | + |
| 273 | + const message = amended.join('\n'); |
| 274 | + const messageFile = this.saveMessage(rev, message); |
| 275 | + cli.separator('New Message'); |
| 276 | + cli.log(message.trim()); |
| 277 | + const takeMessage = await cli.prompt('Use this message?'); |
| 278 | + if (takeMessage) { |
| 279 | + await runAsync('git', ['commit', '--amend', '-F', messageFile]); |
| 280 | + return true; |
| 281 | + } |
| 282 | + |
| 283 | + const editor = await getEditor({ git: true }); |
| 284 | + if (editor) { |
| 285 | + try { |
| 286 | + await forceRunAsync( |
| 287 | + editor, |
| 288 | + [`"${messageFile}"`], |
| 289 | + { ignoreFailure: false, spawnArgs: { shell: true } } |
| 290 | + ); |
| 291 | + await runAsync('git', ['commit', '--amend', '-F', messageFile]); |
| 292 | + return true; |
| 293 | + } catch { |
| 294 | + cli.warn(`Please manually edit ${messageFile}, then run\n` + |
| 295 | + `\`git commit --amend -F ${messageFile}\` ` + |
| 296 | + 'to finish amending the message'); |
| 297 | + throw new Error( |
| 298 | + 'Failed to edit the message using the configured editor'); |
| 299 | + } |
| 300 | + } |
| 301 | + } |
| 302 | +} |
0 commit comments