diff --git a/package.json b/package.json index 0692b88cb384..8041eaf624cc 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "project:sync": "node ./tasks/framework-tools/frameworkSyncToProject.mjs", "release": "node ./tasks/release/cli.mjs", "release:test": "NODE_OPTIONS=--experimental-vm-modules ./node_modules/.bin/jest --config ./tasks/release/jest.config.mjs", + "branch-strategy": "yarn node ./tasks/release/branchStrategy/branchStrategyCLI.mjs", "smoke-test": "cd ./tasks/smoke-test && npx playwright install && npx playwright test", "test": "lerna run test --concurrency 2 -- --colors --maxWorkers=4", "test-ci": "lerna run test --concurrency 2 -- --colors --maxWorkers" diff --git a/tasks/release/branchStrategy/branchStrategyCLI.mjs b/tasks/release/branchStrategy/branchStrategyCLI.mjs new file mode 100644 index 000000000000..5e7ab8c2cd7f --- /dev/null +++ b/tasks/release/branchStrategy/branchStrategyCLI.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/* eslint-env node, es2022 */ + +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +import * as findPRCommand from './findPRCommand.mjs' +import * as triageMainCommand from './triageMainCommand.mjs' +import * as triageNextCommand from './triageNextCommand.mjs' +import * as validateMilestonesCommand from './validateMilestonesCommand.mjs' + +yargs(hideBin(process.argv)) + // Config + .scriptName('branch-strategy') + .demandCommand() + .strict() + // Commands + .command(triageMainCommand) + .command(triageNextCommand) + .command(findPRCommand) + .command(validateMilestonesCommand) + // Run + .parse() diff --git a/tasks/release/branchStrategy/branchStrategyLib.mjs b/tasks/release/branchStrategy/branchStrategyLib.mjs new file mode 100644 index 000000000000..fa3c4099f52a --- /dev/null +++ b/tasks/release/branchStrategy/branchStrategyLib.mjs @@ -0,0 +1,162 @@ +/* eslint-env node, es2022 */ + +import boxen from 'boxen' +import { $, fs, question, chalk } from 'zx' + +export function setupCache(file) { + let cache + + try { + cache = JSON.parse(fs.readFileSync(file, 'utf-8')) + cache = new Map(Object.entries(cache)) + } catch { + cache = new Map() + } + + process.on('exit', () => { + fs.writeFileSync(file, JSON.stringify(Object.fromEntries(cache), null, 2)) + }) + + return cache +} + +export const GIT_LOG_OPTIONS = [ + '--graph', + '--oneline', + '--boundary', + '--cherry-pick', + '--left-only', +] + +export const HASH = /\w{9}/ +export const PR = /#(?\d*)/ + +export function parseCommit(commit) { + const match = commit.match(HASH) + const [hash] = match + + const message = commit.slice(match.index + 10) + + const prMatch = message.match(PR) + const pr = prMatch?.groups.pr + + return { + hash, + message, + pr, + } +} + +export async function isCommitInBranch(branch, message) { + const { stdout } = await $`git log ${branch} --oneline --grep ${message}` + return Boolean(stdout) +} + +export function reportNewCommits(commits) { + console.log( + [ + `There's ${commits.length} commits in the main branch that aren't in the next branch:`, + '', + commits + .map((commit) => { + const { hash, message } = parseCommit(commit) + return `${chalk.bold(chalk.yellow(hash))} ${message}` + }) + .join('\n'), + '', + ].join('\n') + ) +} + +export async function triageCommits(commits) { + for (let commit of commits) { + const { hash, message, pr } = parseCommit(commit) + + // eslint-disable-next-line no-constant-condition + while (true) { + const answer = await question( + `Does ${chalk.bold(chalk.yellow(hash))} ${chalk.cyan( + message + )} need to be cherry picked into ${this.branch}? (Y/n/o(pen)) > ` + ) + + commit = this.cache.get(hash) + + if (answer === 'o' || answer === 'open') { + await $`open https://github.com/redwoodjs/redwood/pull/${pr}` + continue + } + + this.cache.set(hash, { + message: message, + needsCherryPick: answer === '' || answer === 'y' || answer === 'Y', + }) + + break + } + } +} + +export const GIT_LOG_UI = ['o', ' /', '|\\', '| o'] + +export async function getReleaseBranch() { + const { stdout: gitBranchStdout } = await $`git branch --list release/*` + + if (gitBranchStdout.trim().split('\n').length > 1) { + console.log() + console.log("There's more than one release branch") + process.exit(1) + } + + return gitBranchStdout.trim() +} + +export async function purgeCache(cache, commits, branch) { + const commitHashes = commits.map((commit) => parseCommit(commit).hash) + + for (const cachedHash of cache.keys()) { + if (!commitHashes.includes(cachedHash)) { + cache.delete(cachedHash) + } + } + + const needsCherryPick = [...cache.entries()].filter( + ([, { needsCherryPick }]) => needsCherryPick + ) + + for (const [hash, { message }] of needsCherryPick) { + if (await isCommitInBranch(branch, message)) { + cache.delete(hash) + } + } +} + +export async function updateRemotes() { + await $`git remote update` + console.log() + + const { stdout: main } = await $`git rev-list main...origin/main --count` + console.log() + + if (parseInt(main.trim())) { + await $`git fetch origin main:main` + console.log() + } + + const { stdout: next } = await $`git rev-list next...origin/next --count` + console.log() + + if (parseInt(next.trim())) { + await $`git fetch origin next:next` + console.log() + } +} + +export function colorKeyBox(colorKey) { + return boxen(colorKey, { + title: 'Key', + padding: 1, + margin: 1, + borderStyle: 'round', + }) +} diff --git a/tasks/release/branchStrategy/findPRCommand.mjs b/tasks/release/branchStrategy/findPRCommand.mjs new file mode 100644 index 000000000000..02a75277052c --- /dev/null +++ b/tasks/release/branchStrategy/findPRCommand.mjs @@ -0,0 +1,62 @@ +/* eslint-env node, es2022 */ + +import { Octokit } from 'octokit' + +import { + updateRemotes, + isCommitInBranch, + getReleaseBranch, +} from './branchStrategyLib.mjs' + +export const command = 'find-pr ' +export const description = 'Find which branches a PR is in' + +export function builder(yargs) { + yargs.positional('pr', { + description: 'The PR URL', + type: 'string', + }) +} + +export async function handler({ uri }) { + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) + + await updateRemotes() + + const { + resource: { + mergeCommit: { messageHeadline }, + }, + } = await octokit.graphql( + ` + query GetPR($uri: URI!) { + resource(url: $uri) { + ... on PullRequest { + mergeCommit { + messageHeadline + } + } + } + } + `, + { uri } + ) + + const isInNext = await isCommitInBranch('next', messageHeadline) + console.log() + const releaseBranch = await getReleaseBranch() + console.log() + const isInRelease = await isCommitInBranch(releaseBranch, messageHeadline) + console.log() + + console.log( + [ + isInNext + ? '✅ This PR is in the next branch' + : `❌ This PR isn't the next branch`, + isInRelease + ? `✅ This PR is in the ${releaseBranch} branch` + : `❌ This PR isn't the ${releaseBranch} branch`, + ].join('\n') + ) +} diff --git a/tasks/release/branchStrategy/triageMainCache.json b/tasks/release/branchStrategy/triageMainCache.json new file mode 100644 index 000000000000..64155db4d8eb --- /dev/null +++ b/tasks/release/branchStrategy/triageMainCache.json @@ -0,0 +1,130 @@ +{ + "544374612": { + "message": "Fix dbauth webauthn template (redundant type import) (#6769)", + "needsCherryPick": false + }, + "97f6f622e": { + "message": "Fix auth0 decoder import (#6764)", + "needsCherryPick": false + }, + "e2ec41f31": { + "message": "Update netlify auth docs (#6748)", + "needsCherryPick": false + }, + "ce3426b38": { + "message": "Update auth setup warning message (#6746)", + "needsCherryPick": false + }, + "be4c01c1a": { + "message": "Okta: Add packages to setup script (#6732)", + "needsCherryPick": false + }, + "810f1fecf": { + "message": "Azure setup auth: Install and import all needed packages (#6736)", + "needsCherryPick": false + }, + "c7cb9d975": { + "message": "Setup auth: Update goTrue (#6733)", + "needsCherryPick": false + }, + "e05e08071": { + "message": "Auth0 setup: Install correct packages (#6734)", + "needsCherryPick": false + }, + "d0be5e823": { + "message": "nhost auth: Add missing packages (#6742)", + "needsCherryPick": false + }, + "50586ea0b": { + "message": "Add missing packages to magicLink setup (#6741)", + "needsCherryPick": false + }, + "9a2609355": { + "message": "supertokens setup auth: Add missing RW packages (#6744)", + "needsCherryPick": false + }, + "af8970fd5": { + "message": "Missing packages for Ethereum auth setup (#6740)", + "needsCherryPick": false + }, + "570d7b49d": { + "message": "supabase auth setup: Add missing rw packages (#6743)", + "needsCherryPick": false + }, + "968ad3a3c": { + "message": "Update Clerk docs (#6712)", + "needsCherryPick": false + }, + "801894efc": { + "message": "Update firebase auth docs (#6717)", + "needsCherryPick": false + }, + "443506daf": { + "message": "Clerk: Simplify web implementation (#6713)", + "needsCherryPick": false + }, + "60e075f4d": { + "message": "Add auth decoder to clerk auth setup (#6718)", + "needsCherryPick": false + }, + "7fbd6ba32": { + "message": "Update the Clerk setup script and templates (#6710)", + "needsCherryPick": false + }, + "e01750d96": { + "message": "Fix decouple auth related type errors (#6709)", + "needsCherryPick": false + }, + "fa6546440": { + "message": "fix(dbAuth): add required packages to setup command (#6698)", + "needsCherryPick": false + }, + "79adb685e": { + "message": "chore: make misc change to trigger canary publishing (#6695)", + "needsCherryPick": false + }, + "18eaf3007": { + "message": "chore: run lint fix (#6691)", + "needsCherryPick": false + }, + "0942fba9f": { + "message": "Decouple auth (#5985)", + "needsCherryPick": false + }, + "c7ce6d6ac": { + "message": "Custom auth: Fix comment in template (#6804)", + "needsCherryPick": false + }, + "ca4f2bdb5": { + "message": "fix(deps): update jest monorepo (#6818)", + "needsCherryPick": true + }, + "a0b262d0b": { + "message": "fix(deps): update storybook monorepo to v6.5.13 (#6819)", + "needsCherryPick": true + }, + "49d829fb5": { + "message": "Handle multiple set-cookie headers (#6812)", + "needsCherryPick": true + }, + "64a6dce21": { + "message": "fix(deps): update dependency core-js to v3.26.0 (#6822)", + "needsCherryPick": true + }, + "cadb28725": { + "message": "chore(deps): update dependency cypress to v10.11.0 (#6820)", + "needsCherryPick": true + }, + "af3716763": { + "message": "fix(deps): update jest monorepo to v29.3.1 (#6848)", + "needsCherryPick": true + }, + "7cd1204a5": { + "message": "fix(deps): update prisma monorepo to v4.6.0 (#6851)", + "needsCherryPick": true + }, + "1d4b2c4a0": { + "message": "Change to use @iarna/toml instead of toml (#6839)", + "needsCherryPick": true + } +} \ No newline at end of file diff --git a/tasks/release/branchStrategy/triageMainCommand.mjs b/tasks/release/branchStrategy/triageMainCommand.mjs new file mode 100644 index 000000000000..a949cb35f267 --- /dev/null +++ b/tasks/release/branchStrategy/triageMainCommand.mjs @@ -0,0 +1,120 @@ +/* eslint-env node, es2022 */ + +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { $, chalk, path } from 'zx' + +import { + colorKeyBox, + GIT_LOG_OPTIONS, + isCommitInBranch, + parseCommit, + purgeCache, + reportNewCommits, + setupCache, + triageCommits, + updateRemotes, +} from './branchStrategyLib.mjs' + +export const command = 'triage-main' +export const description = 'Triage commits from main to next' + +export async function handler() { + const cache = setupCache( + path.join(dirname(fileURLToPath(import.meta.url)), 'triageMainCache.json') + ) + + await updateRemotes() + + let { stdout } = await $`git log ${GIT_LOG_OPTIONS} main...next` + console.log() + + stdout = stdout.trim().split('\n') + + let commits = stdout + .filter((line) => !line.startsWith('o')) + .filter((line) => !line.includes('chore: update all contributors')) + + await purgeCache(cache, commits, 'next') + + // Remove commits we've already triaged + commits = commits.filter((line) => { + const { hash } = parseCommit(line) + return !cache.has(hash) + }) + + const commitsInNext = await commits.reduce(async (arr, commit) => { + arr = await arr + + const { hash, message } = parseCommit(commit) + + if (await isCommitInBranch('next', message)) { + arr.push(hash) + } + + return arr + }, Promise.resolve([])) + console.log() + + commits = commits.filter( + (commit) => !commitsInNext.includes(parseCommit(commit).hash) + ) + + if (!commits.length) { + console.log('No new commits to triage') + + console.log( + colorKeyBox( + [ + `${chalk.green('■')} Needs to be cherry picked`, + `${chalk.dim( + chalk.red('■') + )} Breaking or builds on breaking (don't cherry pick)`, + `${chalk.dim(chalk.blue('■'))} Cherry picked into next`, + `${chalk.dim('■')} Chore or "boundary" commit (ignore)`, + `${chalk.yellow( + '■' + )} Not in the cache (needs to be manually triaged)`, + ].join('\n') + ) + ) + + stdout.forEach((line, i) => { + if ( + line.startsWith('o') || + line.includes('chore: update all contributors') + ) { + stdout[i] = chalk.dim(line) + return + } + + if (commitsInNext.includes(parseCommit(line).hash)) { + stdout[i] = chalk.dim(chalk.blue(line)) + return + } + + const { hash } = parseCommit(line) + + if (!cache.has(hash)) { + stdout[i] = chalk.yellow(line) + return + } + + if (cache.get(hash).needsCherryPick) { + stdout[i] = chalk.green(line) + return + } + + stdout[i] = chalk.dim(chalk.red(line)) + }) + + console.log(stdout.join('\n')) + + return + } + + reportNewCommits(commits) + + await triageCommits.call({ cache, branch: 'next' }, commits) +} diff --git a/tasks/release/branchStrategy/triageNextCache.json b/tasks/release/branchStrategy/triageNextCache.json new file mode 100644 index 000000000000..7799a464e0b5 --- /dev/null +++ b/tasks/release/branchStrategy/triageNextCache.json @@ -0,0 +1,86 @@ +{ + "646094411": { + "message": "fix(deps): update dependency react-hook-form to v7.39.1 (#6786)", + "needsCherryPick": false + }, + "88581e17f": { + "message": "[CRWA]: Switch to using enquirer, add engine compatibility override option (#6723)", + "needsCherryPick": false + }, + "28226b5e4": { + "message": "fix: move hooks after components (#6797)", + "needsCherryPick": false + }, + "2ec53b601": { + "message": "[Tutorial]: Fix Typescript code blocks inconsistency (#6801)", + "needsCherryPick": false + }, + "52f3ca42c": { + "message": "fix(deps): update dependency eslint to v8.26.0 (#6785)", + "needsCherryPick": false + }, + "be96b5535": { + "message": "(docs): Minor Command update about Storybook (#6722)", + "needsCherryPick": false + }, + "91ae7b6ac": { + "message": "docs: Add mocking useLocation to docs (#6791)", + "needsCherryPick": false + }, + "97c13754c": { + "message": "fix(deps): update jest monorepo (#6787)", + "needsCherryPick": false + }, + "737ed08ab": { + "message": "fix: publish canary using premajor (#6794)", + "needsCherryPick": false + }, + "7954ef0e3": { + "message": "chore(deps): update dependency @replayio/playwright to v0.3.2 (#6816)", + "needsCherryPick": false + }, + "f00c3eb51": { + "message": "fix(deps): update dependency fastify-raw-body to v4.1.1 (#6817)", + "needsCherryPick": false + }, + "a66a57902": { + "message": "(crwa): Add git init option (#6805)", + "needsCherryPick": false + }, + "45da5f312": { + "message": "fix(deps): update dependency webpack to v5.75.0 (#6849)", + "needsCherryPick": false + }, + "4ef01879d": { + "message": "fix(deps): update dependency @fastify/http-proxy to v8.3.0 (#6821)", + "needsCherryPick": false + }, + "3fa6f8eb8": { + "message": "docs(tutorial): Fix wrong typing on createContact service (#6810)", + "needsCherryPick": false + }, + "87adc34cc": { + "message": "chore(generate): Refactor how page tests are written (#6825)", + "needsCherryPick": false + }, + "0b83391a0": { + "message": "docs: Fix capitalization of \"--webauthn\" (#6815)", + "needsCherryPick": false + }, + "280119efb": { + "message": "chore(generate): refactor layout generator tests for performance (#6826)", + "needsCherryPick": false + }, + "075aa1e51": { + "message": "chore(generate): refactor functions generator tests for performance (#6827)", + "needsCherryPick": false + }, + "fa61ea5bd": { + "message": "chore(generate): update scaffoldPath test (#6828)", + "needsCherryPick": false + }, + "f22619e87": { + "message": "fix(deps): update dependency pino to v8.7.0 (#6823)", + "needsCherryPick": false + } +} diff --git a/tasks/release/branchStrategy/triageNextCommand.mjs b/tasks/release/branchStrategy/triageNextCommand.mjs new file mode 100644 index 000000000000..0777e73cbcf1 --- /dev/null +++ b/tasks/release/branchStrategy/triageNextCommand.mjs @@ -0,0 +1,145 @@ +/* eslint-env node, es2022 */ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { $, path, chalk } from 'zx' + +import { + updateRemotes, + setupCache, + GIT_LOG_OPTIONS, + GIT_LOG_UI, + purgeCache, + parseCommit, + isCommitInBranch, + reportNewCommits, + triageCommits, + getReleaseBranch, + colorKeyBox, +} from './branchStrategyLib.mjs' + +export const command = 'triage-next' +export const description = 'Triage commits from next to release' + +export async function handler() { + await updateRemotes() + + const branch = await getReleaseBranch() + console.log() + + const cache = setupCache( + path.join(dirname(fileURLToPath(import.meta.url)), 'triageNextCache.json') + ) + + let { stdout } = await $`git log ${GIT_LOG_OPTIONS} next...${branch}` + + if (!stdout) { + console.log(`The next and ${branch} branches are the same`) + cache.clear() + return + } + + console.log() + + stdout = stdout.trim().split('\n') + + let commits = stdout + .filter((line) => !GIT_LOG_UI.some((mark) => line.startsWith(mark))) + // Remove any commits that are chores from merging a release branch back into the next branch. + .filter((line) => !line.includes('chore: update yarn.lock')) + .filter((line) => !/Merge branch (?.*) into next/.test(line)) + .filter((line) => { + const { message } = parseCommit(line) + return !TAG_COMMIT_MESSAGE.test(message) + }) + + await purgeCache(cache, commits, branch) + + // ? + + // Remove commits we've already triaged + commits = commits.filter((line) => { + const { hash } = parseCommit(line) + return !cache.has(hash) + }) + + const commitsInRelease = await commits.reduce(async (arr, commit) => { + arr = await arr + + const { hash, message } = parseCommit(commit) + + if (await isCommitInBranch(branch, message)) { + arr.push(hash) + } + + return arr + }, Promise.resolve([])) + console.log() + + commits = commits.filter( + (commit) => !commitsInRelease.includes(parseCommit(commit).hash) + ) + + if (!commits.length) { + console.log('No new commits to triage') + + console.log( + colorKeyBox( + [ + `${chalk.green('■')} Needs to be cherry picked`, + `${chalk.dim(chalk.red('■'))} Doesn't need to be cherry picked)`, + `${chalk.dim(chalk.blue('■'))} Cherry picked into ${branch}`, + `${chalk.dim('■')} Chore or "boundary" commit (ignore)`, + `${chalk.yellow( + '■' + )} Not in the cache (needs to be manually triaged)`, + ].join('\n') + ) + ) + + stdout.forEach((line, i) => { + if ( + GIT_LOG_UI.some((mark) => line.startsWith(mark)) || + line.includes('chore: update yarn.lock') || + /Merge branch (?.*) into next/.test(line) + ) { + stdout[i] = chalk.dim(line) + return + } + + const { hash, message } = parseCommit(line) + + if (TAG_COMMIT_MESSAGE.test(message)) { + stdout[i] = chalk.dim(line) + return + } + + if (commitsInRelease.includes(parseCommit(line).hash)) { + stdout[i] = chalk.dim(chalk.blue(line)) + return + } + + if (!cache.has(hash)) { + stdout[i] = chalk.yellow(line) + return + } + + if (cache.get(hash).needsCherryPick) { + stdout[i] = chalk.green(line) + return + } + + stdout[i] = chalk.dim(chalk.red(line)) + }) + + console.log(stdout.join('\n')) + + return + } + + reportNewCommits(commits) + + await triageCommits.call({ cache, branch }, commits) +} + +const TAG_COMMIT_MESSAGE = /^v\d.\d.\d$/ diff --git a/tasks/release/branchStrategy/validateMilestonesCommand.mjs b/tasks/release/branchStrategy/validateMilestonesCommand.mjs new file mode 100644 index 000000000000..ece238ed7bab --- /dev/null +++ b/tasks/release/branchStrategy/validateMilestonesCommand.mjs @@ -0,0 +1,130 @@ +/* eslint-env node, es2022 */ + +import { Octokit } from 'octokit' +import { chalk } from 'zx' + +import { isCommitInBranch, getReleaseBranch } from './branchStrategyLib.mjs' + +export const command = 'validate-milestones' +export const description = 'Validate PRs with the "next-release" milestone' + +export async function handler() { + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) + + const { + node: { + pullRequests: { nodes: prs }, + }, + } = await octokit.graphql(getNextReleasePRs) + + const branch = await getReleaseBranch() + console.log() + + let { + repository: { + milestones: { nodes: milestones }, + }, + } = await octokit.graphql(getMilestoneIds) + + milestones = milestones.reduce((obj, { title, id }) => { + obj[title] = id + return obj + }, {}) + + for (const pr of prs) { + if (await isCommitInBranch(branch, pr.mergeCommit.messageHeadline)) { + console.log( + [ + `${chalk.red('error')}: pr #${ + pr.number + } should be milestoned ${chalk.green(branch.split('/')[2])}`, + `${chalk.blue('fixing')}: milestoning PR #${ + pr.number + } to ${chalk.green(branch.split('/')[2])}`, + ].join('\n') + ) + + await octokit.graphql(milestonePullRequest, { + pullRequestId: pr.id, + milestoneId: milestones[branch.split('/')[2]], + }) + + console.log(chalk.green('done')) + console.log() + + continue + } + + if (await isCommitInBranch('next', pr.mergeCommit.messageHeadline)) { + console.log( + `${chalk.green('ok')}: pr #${ + pr.number + } should be milestoned next release` + ) + console.log(chalk.green('done')) + console.log() + continue + } + + console.log( + [ + `${chalk.red('error')}: pr #${ + pr.number + } should be milestoned ${chalk.green('v4.0.0')}`, + `${chalk.blue('fixing')}: milestoning PR #${pr.number} to ${chalk.green( + 'v4.0.0' + )}`, + ].join('\n') + ) + + await octokit.graphql(milestonePullRequest, { + pullRequestId: pr.id, + milestoneId: milestones['v4.0.0'], + }) + + console.log(chalk.green('done')) + console.log() + } +} + +const getNextReleasePRs = ` + query GetNextReleasePRs { + node(id: "MI_kwDOC2M2f84Aa82f") { + ... on Milestone { + pullRequests(first: 100) { + nodes { + id + number + title + mergeCommit { + messageHeadline + } + } + } + } + } + } +` + +const getMilestoneIds = ` + query GetMilestoneIds { + repository(owner: "redwoodjs", name: "redwood") { + milestones(first: 10, states: OPEN) { + nodes { + id + title + } + } + } + } +` + +const milestonePullRequest = ` + mutation MilestonePullRequest($pullRequestId: ID!, $milestoneId: ID!) { + updatePullRequest( + input: { pullRequestId: $pullRequestId, milestoneId: $milestoneId } + ) { + clientMutationId + } + } +`