diff --git a/lib/cherry_pick.ts b/lib/cherry_pick.ts new file mode 100644 index 0000000..07f72f3 --- /dev/null +++ b/lib/cherry_pick.ts @@ -0,0 +1,64 @@ +import { chalk, question, $ } from 'zx' + +import { consoleBoxen } from './console_helpers.js' +import { getUserLogin } from './github.js' +import { resolveRes } from './prompts.js' +import type { Commit, Range } from './types.js' + +export async function cherryPickCommits(commits: Commit[], { + range, + afterCherryPick, +}: { + range: Range + afterCherryPick?: (commit: Commit) => Promise +}) { + const login = await getUserLogin() + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i] + consoleBoxen(`🧮 Triaging ${i + 1} of ${commits.length}`, commit.line) + + while (true) { + const res = resolveRes(await question('Ok to cherry pick? [Y/n/o(pen)] > ')) + + if (res === 'open') { + if (commit.url) { + await $`open ${commit.url}` + } else { + console.log("There's no PR associated with this commit") + } + continue + } else if (res === 'no') { + let res = await question('Add a note explaining why not > ') + res = `(${login}) ${res}` + await $`git notes add -m ${res} ${commit.hash}` + await $`git notes show ${commit.hash}` + console.log(`You can edit the note with \`git notes edit ${commit.hash}\``) + break + } + + try { + await $`git switch ${range.to}` + await $`git cherry-pick ${commit.hash}` + console.log() + console.log(chalk.green('🌸 Successfully cherry picked')) + await afterCherryPick?.(commit) + break + } catch (error) { + console.log() + console.log(chalk.yellow("✋ Couldn't cleanly cherry pick. Resolve the conflicts and run `git cherry-pick --continue`")) + await question('Press anything to continue > ') + await afterCherryPick?.(commit) + break + } + } + + console.log() + } + + consoleBoxen('🏁 Finish!', `All ${commits.length} commits have been triaged`) +} + +export function reportCommitsEligibleForCherryPick(commits: Commit[]) { + consoleBoxen(`🧮 ${commits.length} commits to triage`, commits.map(commit => commit.line).join('\n')) +} diff --git a/lib/boxen.ts b/lib/console_helpers.ts similarity index 77% rename from lib/boxen.ts rename to lib/console_helpers.ts index ee7ec20..638b2f3 100755 --- a/lib/boxen.ts +++ b/lib/console_helpers.ts @@ -1,4 +1,5 @@ import boxen from 'boxen' +import { chalk } from 'zx' export function consoleBoxen(title: string, message: string) { console.error( @@ -15,3 +16,5 @@ export function consoleBoxen(title: string, message: string) { }) ) } + +export const separator = chalk.dim('-'.repeat(process.stdout.columns)) diff --git a/lib/error.ts b/lib/custom_error.ts similarity index 100% rename from lib/error.ts rename to lib/custom_error.ts diff --git a/lib/debug_logger.ts b/lib/debug_logger.ts deleted file mode 100644 index fbafcfc..0000000 --- a/lib/debug_logger.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function debugLogger(arg: any) { - if (process.env.REDWOOD_RELEASE_DEBUG) { - console.dir(arg, { depth: null }) - } -} diff --git a/lib/git.ts b/lib/git.ts index 0cfc69e..6f6310c 100755 --- a/lib/git.ts +++ b/lib/git.ts @@ -1,7 +1,7 @@ import semver from 'semver' import { chalk, $ } from 'zx' -import { CustomError } from './error.js' +import { CustomError } from './custom_error.js' import { unwrap } from './zx_helpers.js' /** Gets release branches (e.g. `release/major/v7.0.0`, etc.) */ @@ -13,7 +13,7 @@ export async function getReleaseBranches() { const releaseBranches = releaseBranchesStdout .split('\n') - .map((branch) => branch.trim()) + .map((branch) => branch.trim().replace('* ', '')) .sort((releaseBranchA, releaseBranchB) => { const [, , versionA] = releaseBranchA.split('/') const [, , versionB] = releaseBranchB.split('/') @@ -30,6 +30,7 @@ export async function assertWorkTreeIsClean() { `The working tree at ${chalk.magenta(process.cwd())} isn't clean. Commit or stash your changes.` ); } + console.log('✨ The working tree is clean') } export async function branchExists(branch: string) { @@ -44,6 +45,7 @@ export async function assertBranchExists(branch: string) { chalk.green(` git checkout -b ${branch} /${branch}`), ].join('\n')) } + console.log(`🏠 The ${chalk.magenta(branch)} branch exists locally`) } export async function getRedwoodRemote() { @@ -52,9 +54,40 @@ export async function getRedwoodRemote() { for (const remote of remotes.split('\n')) { const match = remote.match(/^(?.+)\s.+redwoodjs\/redwood/) if (match?.groups) { + console.log(`📡 Got Redwood remote ${chalk.magenta(match.groups.remote)}`) return match.groups.remote } } throw new CustomError(`Couldn't find the remote for the Redwood monorepo.`) } + +export const commitRegExps = { + hash: /(?\w{40})\s/, + pr: /\(#(?\d+)\)$/, + annotatedTag: /^v\d.\d.\d$/, +} + +/** Get a commit's hash */ +export function getCommitHash(line: string) { + const match = line.match(commitRegExps.hash) + + if (!match?.groups) { + throw new Error([ + `Couldn't find a commit hash in the line "${line}"`, + "This most likely means that a line that's UI isn't being identified as such", + ].join('\n')) + } + + return match.groups.hash +} + +/** Square brackets (`[` or `]`) in commit messages need to be escaped */ +function sanitizeMessage(message: string) { + return message.replace('[', '\\[').replace(']', '\\]') +} + +export async function commitIsInRef(ref: string, message: string) { + message = sanitizeMessage(message) + return unwrap(await $`git log ${ref} --oneline --grep ${message}`) +} diff --git a/lib/github.ts b/lib/github.ts index ab64831..404b416 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -1,40 +1,39 @@ -import { $ } from 'zx' +import { chalk, $ } from 'zx' -import { CustomError } from './error.js' +import { CustomError } from './custom_error.js' -/** Get the GitHub token from REDWOOD_GITHUB_TOKEN */ export function getGitHubToken() { const gitHubToken = process.env.REDWOOD_GITHUB_TOKEN - if (!gitHubToken) { throw new CustomError("The `REDWOOD_GITHUB_TOKEN` environment variable isn't set") } - return gitHubToken } -export async function gqlGitHub({ query, variables }: { query: string; variables?: Record }) { +export function getGitHubFetchHeaders() { const gitHubToken = getGitHubToken() + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${gitHubToken}`, + } +} +export async function gqlGitHub({ query, variables }: { query: string; variables?: Record }) { + const headers = getGitHubFetchHeaders() const res = await fetch('https://api.github.com/graphql', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${gitHubToken}`, - }, + headers, body: JSON.stringify({ query, variables, }), }) - const body = await res.json() - return body } export async function getUserLogin() { - const { data } = await gqlGitHub({ query: `query { viewer { login } }`}) + const { data } = await gqlGitHub({ query: `query { viewer { login } }` }) return data.viewer.login } @@ -54,6 +53,7 @@ export async function pushBranch(branch: string, remote: string) { */ export async function fetchNotes(remote: string) { await $`git fetch ${remote} 'refs/notes/*:refs/notes/*'` + console.log(`Fetched notes from ${remote}`) } export async function pushNotes(remote: string) { diff --git a/lib/cwd.ts b/lib/set_cwd.ts similarity index 90% rename from lib/cwd.ts rename to lib/set_cwd.ts index 1e97b98..e543ca3 100755 --- a/lib/cwd.ts +++ b/lib/set_cwd.ts @@ -1,6 +1,6 @@ import { cd, chalk, fs } from "zx" -import { CustomError } from './error.js' +import { CustomError } from './custom_error.js' export async function setCwd() { let RWFW_PATH = process.env.RWFW_PATH; @@ -30,5 +30,6 @@ export async function setCwd() { const originalCwd = process.cwd() cd(RWFW_PATH) + console.log(`📂 Working in ${chalk.magenta(RWFW_PATH)}`) return () => cd(originalCwd) } diff --git a/triage/lib/types.ts b/lib/types.ts old mode 100755 new mode 100644 similarity index 100% rename from triage/lib/types.ts rename to lib/types.ts diff --git a/release/lib/milestones.test.ts b/release/lib/milestones.test.ts index 6e3a8de..970eec1 100644 --- a/release/lib/milestones.test.ts +++ b/release/lib/milestones.test.ts @@ -8,31 +8,34 @@ describe('getPrsWithMilestone', () => { expect(prs).toMatchInlineSnapshot(` [ { - "id": "PR_kwDOC2M2f85nyT1x", + "id": "PR_kwDOC2M2f85nkDDh", "mergeCommit": { - "messageHeadline": "Update studio.md (#10062)", + "messageHeadline": "Update MetaTags to be MetaData in Docs (#10053)", }, - "number": 10062, - "title": "Update studio.md", - "url": "https://github.com/redwoodjs/redwood/pull/10062", + "mergedAt": "2024-02-22T17:44:49Z", + "number": 10053, + "title": "Update MetaTags to be Metadata in Docs", + "url": "https://github.com/redwoodjs/redwood/pull/10053", }, { "id": "PR_kwDOC2M2f85nszPR", "mergeCommit": { "messageHeadline": "fix(render): reduce memory and handle server file (#10055)", }, + "mergedAt": "2024-02-23T10:04:32Z", "number": 10055, "title": "fix(render): reduce memory and handle server file ", "url": "https://github.com/redwoodjs/redwood/pull/10055", }, { - "id": "PR_kwDOC2M2f85nkDDh", + "id": "PR_kwDOC2M2f85nyT1x", "mergeCommit": { - "messageHeadline": "Update MetaTags to be MetaData in Docs (#10053)", + "messageHeadline": "Update studio.md (#10062)", }, - "number": 10053, - "title": "Update MetaTags to be Metadata in Docs", - "url": "https://github.com/redwoodjs/redwood/pull/10053", + "mergedAt": "2024-02-24T01:21:51Z", + "number": 10062, + "title": "Update studio.md", + "url": "https://github.com/redwoodjs/redwood/pull/10062", }, ] `) diff --git a/release/lib/milestones.ts b/release/lib/milestones.ts index ffa83e6..b55946f 100644 --- a/release/lib/milestones.ts +++ b/release/lib/milestones.ts @@ -1,11 +1,7 @@ -import { $ } from 'zx' +import { CustomError } from '@lib/custom_error.js' +import { getGitHubFetchHeaders, gqlGitHub } from '@lib/github.js' -import { CustomError } from '@lib/error.js' -import { gqlGitHub } from '@lib/github.js' -import { debugLogger } from '@lib/debug_logger.js' -// import { unwrap } from '@lib/zx_helpers.js' - -import { PR } from './types.js' +import type { PR } from './types.js' export async function getPrsWithMilestone(milestone?: string): Promise { const search = [ @@ -18,11 +14,15 @@ export async function getPrsWithMilestone(milestone?: string): Promise { } else { search.push(`milestone:${milestone}`) } - debugLogger(search.join(' ')) const res = await gqlGitHub({ query: prsQuery, variables: { search: search.join(' ') } }) - debugLogger(res) - return res.data.search.nodes + const prs = res.data.search.nodes + + prs.sort((a, b) => { + return new Date(a.mergedAt) > new Date(b.mergedAt) ? 1 : -1 + }) + + return prs } const prsQuery = `\ @@ -41,12 +41,19 @@ const prsQuery = `\ mergeCommit { messageHeadline } + mergedAt } } } } ` +const milestonesToIds = { + 'chore': 'MDk6TWlsZXN0b25lNjc4MjU1MA==', + 'next-release': 'MI_kwDOC2M2f84Aa82f', + 'next-release-patch': 'MDk6TWlsZXN0b25lNjc1Nzk0MQ==', +} + export async function assertNoNoMilestonePrs() { const noMilestonePrs = await getPrsWithMilestone() @@ -58,35 +65,77 @@ export async function assertNoNoMilestonePrs() { } } -// export async function getPrs({ desiredSemver }: ReleaseOptions) { -// // Handle PRs that have been merged without a milestone. We have a check in CI for this, so it really shouldn't happen. -// // But if it does, we handle it here. -// let prs = await getPrsWithMilestone('next-release-patch') - -// if (desiredSemver === 'minor') { -// const nextReleasePrs = await getPrsWithMilestone('next-release') -// prs = [ -// ...prs, -// ...nextReleasePrs -// ] -// } - -// prs.map(async (pr) => { -// return { -// ...pr, -// hash: await getHashForPr(pr) -// } -// }) - -// return prs -// } - -// /** Square brackets (`[` or `]`) in commit messages need to be escaped */ -// function sanitizeMessage(message: string) { -// return message.replace('[', '\\[').replace(']', '\\]') -// } - -// async function getHashForPr(message: string) { -// message = sanitizeMessage(message) -// const res = unwrap(await $`git log next --oneline --grep ${message}`) -// } +export async function updatePrMilestone(prId: string, milestoneId: string) { + return gqlGitHub({ + query: updatePrMilestoneMutation, + variables: { + prId, + milestoneId: milestoneId, + }, + }) +} + +const updatePrMilestoneMutation = `\ + mutation ($prId: ID!, $milestoneId: ID) { + updatePullRequest(input: { pullRequestId: $prId, milestoneId: $milestoneId }) { + clientMutationId + } + } +` + +export async function createMilestone(title: string) { + const headers = await getGitHubFetchHeaders() + const res = await fetch(`https://api.github.com/repos/redwoodjs/redwood/milestones`, { + method: 'POST', + headers, + body: JSON.stringify({ title }), + }) + const json = await res.json() + return { + id: json.node_id, + title: json.title, + number: json.number, + } +} + +export async function closeMilestone(title: string) { + const milestone = await getMilestone(title) + const headers = await getGitHubFetchHeaders() + const res = await fetch(`https://api.github.com/repos/redwoodjs/redwood/milestones/${milestone.number}`, { + method: 'POST', + headers, + body: JSON.stringify({ state: 'closed' }), + }) + const json = await res.json() + console.log(json) +} + +async function getMilestones() { + const res = await gqlGitHub({ query: getMilestonesQuery }) + return res.data.repository.milestones.nodes +} + +const getMilestonesQuery = `\ + { + repository(owner: "redwoodjs", name: "redwood") { + milestones(first: 100, states: OPEN) { + nodes { + id + title + number + } + } + } + } +` + +export async function getMilestone(title: string) { + const milestones = await getMilestones() + let milestone = milestones.find((milestone) => milestone.title === title) + if (milestone) { + return milestone + } + + milestone = await createMilestone(title) + return milestone +} diff --git a/release/lib/release.ts b/release/lib/release.ts index 776dfad..e852ba4 100644 --- a/release/lib/release.ts +++ b/release/lib/release.ts @@ -1,18 +1,74 @@ import { execaCommand } from 'execa' +import semver from 'semver' import { cd, chalk, fs, path, question, $ } from 'zx' -import { CustomError } from '@lib/error.js' -import { branchExists } from '@lib/git.js' +import { separator } from '@lib/console_helpers.js' +import { cherryPickCommits, reportCommitsEligibleForCherryPick } from '@lib/cherry_pick.js' +import { CustomError } from '@lib/custom_error.js' +import { branchExists, commitIsInRef, getCommitHash } from '@lib/git.js' +import { pushBranch } from '@lib/github.js' import { resIsYes } from '@lib/prompts.js' +import { unwrap } from "@lib/zx_helpers.js"; -import { ReleaseOptions } from './types.js' +import { closeMilestone, getMilestone, getPrsWithMilestone, updatePrMilestone } from './milestones.js' +import type { ReleaseOptions } from './types.js' + +export async function assertLoggedInToNpm() { + try { + await $`npm whoami` + console.log('🔑 Logged in to NPM') + } catch (error) { + throw new CustomError([ + `You're Not logged in to NPM. Log in with ${chalk.magenta('npm login')}`, + ].join('\n')) + } +} + +export async function getLatestReleaseOrThrow() { + const latestRelease = unwrap( + await $`git tag --sort="-version:refname" --list "v?.?.?" | head -n 1` + ) + let ok = resIsYes(await question( + `The latest release is ${chalk.magenta(latestRelease)}? [Y/n] > ` + )) + if (!ok) { + throw new CustomError("The latest release isn't correct") + } + return latestRelease +} + +export async function getNextReleaseOrThrow({ latestRelease, desiredSemver }: Pick) { + const nextRelease = `v${semver.inc(latestRelease, desiredSemver)}` + const ok = resIsYes( + await question( + `The next release is ${chalk.magenta(nextRelease)}? [Y/n] > ` + ) + ) + if (!ok) { + throw new CustomError("The next release isn't correct") + } + return nextRelease +} + +/** + * If the git tag for the desired semver already exists, this script was run before, but not to completion. + * The git tag is one of the last steps, so we need to delete it first. + */ +export async function assertGitTagDoesntExist({ nextRelease }: Pick) { + const gitTagAlreadyExists = unwrap(await $`git tag -l ${nextRelease}`) + if (gitTagAlreadyExists) { + throw new CustomError('The git tag already exists') + } +} export async function release(options: ReleaseOptions) { const releaseBranch = ['release', options.desiredSemver, options.nextRelease].join('/') await switchToReleaseBranch({ ...options, releaseBranch }) - // TODO(jtoar): There's more to a patch release that should go here. + console.log(separator) + await updateReleaseBranch({ ...options, releaseBranch }) + console.log(separator) const message = options.desiredSemver === 'patch' ? `Ok to ${chalk.underline('reversion')} ${chalk.magenta(options.nextRelease)} docs? [Y/n] > ` : `Ok to ${chalk.underline('version')} docs to ${chalk.magenta(options.nextRelease)}? [Y/n] > ` @@ -20,16 +76,20 @@ export async function release(options: ReleaseOptions) { if (okToVersionDocs) { await versionDocs(options) } + + console.log(separator) await question('Press anything to clean, install, and update package versions > ') await $`git clean -fxd` await $`yarn install` await updatePackageVersions(options) + console.log(separator) await question('Press anything to run build, lint, and test > ') await $`yarn build` await $`yarn lint` await $`yarn test` + console.log(separator) const ok = resIsYes(await question(`Ok to publish to NPM? [Y/n] > `)) if (!ok) { throw new CustomError("See you later!", "👋") @@ -40,10 +100,12 @@ export async function release(options: ReleaseOptions) { // Undo the temporary commit and publish CRWA. await undoRemoveCreateRedwoodAppFromWorkspaces() + console.log(separator) await question('Press anything to update create-redwood-app templates > ') await updateCreateRedwoodAppTemplates() await publish() + console.log(separator) await question('Press anything consolidate commits, tag, and push > ') // This combines the update package versions commit and update CRWA commit into one. await $`git reset --soft HEAD~2` @@ -52,7 +114,11 @@ export async function release(options: ReleaseOptions) { await $`git tag -am ${options.nextRelease} "${options.nextRelease}"` await $`git push -u ${options.remote} ${releaseBranch} --follow-tags` - // TODO(jtoar): Offer to merge the release branch into main/next. + await closeMilestone(options.nextRelease) + + console.log(separator) + await question('Press anything to merge the release branch into next >') + await mergeReleaseBranch(releaseBranch) } async function switchToReleaseBranch({ releaseBranch, latestRelease }: Pick & { releaseBranch: string }) { @@ -94,6 +160,53 @@ async function switchToReleaseBranch({ releaseBranch, latestRelease }: Pick { + await updatePrMilestone(pr.id, milestone.id) + } + }) + console.log(separator) + const okToPushBranch = resIsYes(await question(`Ok to push ${options.releaseBranch}? [Y/n] > `)) + if (okToPushBranch) { + await pushBranch(options.releaseBranch, options.remote) + } +} + async function versionDocs({ desiredSemver, nextRelease }: Pick) { const nextDocsVersion = nextRelease.slice(1, -2) await cd('./docs') @@ -109,7 +222,15 @@ async function versionDocs({ desiredSemver, nextRelease }: Pick ` - )) - if (!ok) { - throw new CustomError("The latest release isn't correct") - } - return latestRelease -} - -export async function getNextReleaseOrThrow({ latestRelease, desiredSemver }: Pick) { - const nextRelease = `v${semver.inc(latestRelease, desiredSemver)}` - const ok = resIsYes( - await question( - `The next release is ${chalk.magenta(nextRelease)}? [Y/n] > ` - ) - ) - if (!ok) { - throw new CustomError("The next release isn't correct") - } - return nextRelease -} - -/** - * If the git tag for the desired semver already exists, this script was run before, but not to completion. - * The git tag is one of the last steps, so we need to delete it first. - */ -export async function assertGitTagDoesntExist({ nextRelease }: Pick) { - const gitTagAlreadyExists = unwrap(await $`git tag -l ${nextRelease}`) - if (gitTagAlreadyExists) { - throw new CustomError('The git tag already exists') - } -} diff --git a/release/run.ts b/release/run.ts index da80a81..f5c34d0 100644 --- a/release/run.ts +++ b/release/run.ts @@ -1,24 +1,32 @@ -import { consoleBoxen } from '@lib/boxen.js' -import { setCwd } from '@lib/cwd.js' -import { CustomError } from '@lib/error.js' +import { consoleBoxen, separator } from '@lib/console_helpers.js' +import { setCwd } from '@lib/set_cwd.js' +import { CustomError } from '@lib/custom_error.js' import { assertWorkTreeIsClean, getRedwoodRemote } from '@lib/git.js' import { getDesiredSemver } from '@lib/prompts.js' -import { assertGitTagDoesntExist, getLatestReleaseOrThrow, getNextReleaseOrThrow } from './lib/x.js' import { assertNoNoMilestonePrs } from './lib/milestones.js' -import { release } from './lib/release.js' +import { assertLoggedInToNpm, assertGitTagDoesntExist, getLatestReleaseOrThrow, getNextReleaseOrThrow, release } from './lib/release.js' try { + await assertLoggedInToNpm() + + console.log(separator) await setCwd() + + console.log(separator) await assertWorkTreeIsClean() await assertNoNoMilestonePrs() + + console.log(separator) const remote = await getRedwoodRemote() + console.log(separator) const desiredSemver = await getDesiredSemver() const latestRelease = await getLatestReleaseOrThrow() const nextRelease = await getNextReleaseOrThrow({ latestRelease, desiredSemver }) await assertGitTagDoesntExist({ nextRelease }) + console.log(separator) await release({ latestRelease, nextRelease, desiredSemver, remote }) } catch (error) { process.exitCode = 1; diff --git a/scripts/reset_next.ts b/scripts/reset_next.ts index 77538b9..da910cd 100644 --- a/scripts/reset_next.ts +++ b/scripts/reset_next.ts @@ -5,7 +5,7 @@ import { question, $ } from 'zx' -import { setCwd } from '@lib/cwd.js' +import { setCwd } from '@lib/set_cwd.js' import { getRedwoodRemote } from '@lib/git.js' import { resIsYes } from '@lib/prompts.js' diff --git a/triage/lib/tokens.ts b/triage/lib/colors.ts similarity index 70% rename from triage/lib/tokens.ts rename to triage/lib/colors.ts index 8c3cf2f..0551d31 100755 --- a/triage/lib/tokens.ts +++ b/triage/lib/colors.ts @@ -5,5 +5,3 @@ export const colors = { shouldntBeCherryPicked: chalk.dim.red, choreOrDecorative: chalk.dim, } - -export const separator = chalk.dim('-'.repeat(process.stdout.columns)) diff --git a/triage/lib/options.ts b/triage/lib/options.ts index b1a6736..176539d 100755 --- a/triage/lib/options.ts +++ b/triage/lib/options.ts @@ -3,6 +3,7 @@ import { prompts } from '@lib/prompts.js' export async function getOptions() { const releaseBranches = await getReleaseBranches(); + console.log() const choices = [ "main...next", diff --git a/triage/lib/resolve_symmetric_difference.test.ts b/triage/lib/resolve_symmetric_difference.test.ts index d2587ec..3f5577e 100755 --- a/triage/lib/resolve_symmetric_difference.test.ts +++ b/triage/lib/resolve_symmetric_difference.test.ts @@ -2,7 +2,7 @@ import { fs, $ } from 'zx' import { beforeAll, afterAll, describe, expect, it } from 'vitest' -import { setCwd } from '../../lib/cwd.js' +import { setCwd } from '../../lib/set_cwd.js' import { resolveSymmetricDifference } from './symmetric_difference.js' $.verbose = false diff --git a/triage/lib/symmetric_difference.ts b/triage/lib/symmetric_difference.ts index d4b23b2..73cb825 100755 --- a/triage/lib/symmetric_difference.ts +++ b/triage/lib/symmetric_difference.ts @@ -2,11 +2,12 @@ import { fileURLToPath } from 'node:url' import { fs, $ } from "zx"; +import { commitRegExps, commitIsInRef, getCommitHash } from '@lib/git.js' import { gqlGitHub } from '@lib/github.js' +import type { Commit, Range } from "@lib/types.js"; import { unwrap } from "@lib/zx_helpers.js"; -import { colors } from './tokens.js' -import type { Commit, Range } from "./types.js"; +import { colors } from './colors.js' export const defaultGitLogOptions = [ "--oneline", @@ -89,26 +90,6 @@ export async function resolveLine(line: string, { range }: { range: Range }) { return commit } -const commitRegExps = { - hash: /\s(?\w{40})\s/, - pr: /\(#(?\d+)\)$/, - annotatedTag: /^v\d.\d.\d$/, -} - -/** Get a commit's hash */ -export function getCommitHash(line: string) { - const match = line.match(commitRegExps.hash) - - if (!match?.groups) { - throw new Error([ - `Couldn't find a commit hash in the line "${line}"`, - "This most likely means that a line that's UI isn't being identified as such", - ].join('\n')) - } - - return match.groups.hash -} - /** Get a commit's message from its 40-character hash */ export async function getCommitMessage(hash: string) { return unwrap(await $`git log --format=%s -n 1 ${hash}`) @@ -163,16 +144,6 @@ export async function getCommitMilestone(prUrl: string) { return milestone } -/** Square brackets (`[` or `]`) in commit messages need to be escaped */ -function sanitizeMessage(message: string) { - return message.replace('[', '\\[').replace(']', '\\]') -} - -export async function commitIsInRef(ref: string, message: string) { - message = sanitizeMessage(message) - return unwrap(await $`git log ${ref} --oneline --grep ${message}`) -} - const MARKS = ["o", "/", "|\\", "| o", "|\\|", "|/"]; /** Determine if a line from `git log --graph` is just UI */ diff --git a/triage/lib/triage.test.ts b/triage/lib/triage.test.ts index 3816b15..31fbae7 100755 --- a/triage/lib/triage.test.ts +++ b/triage/lib/triage.test.ts @@ -2,10 +2,11 @@ import { chalk, $ } from 'zx' import { beforeAll, afterAll, describe, expect, it, test } from 'vitest' -import { setCwd } from '../../lib/cwd.js' +import { getCommitHash } from '@lib/git.js' + +import { setCwd } from '../../lib/set_cwd.js' import { defaultGitLogOptions, - getCommitHash, getCommitMessage, getCommitMilestone, getCommitPr, @@ -17,7 +18,7 @@ import { PADDING, resolveLine, } from './symmetric_difference.js' -import { colors } from './tokens.js' +import { colors } from './colors.js' import { commitIsEligibleForCherryPick } from './triage.js' $.verbose = false @@ -224,6 +225,12 @@ describe('getCommitHash', () => { This most likely means that a line that's UI isn't being identified as such] `) }) + + it('works for non left-right lines', () => { + const hash = getCommitHash("487548234b49bb93bb79ad89c7ac4a91ed6c0dc9 chore(deps): update dependency @playwright/test to v1.41.2 (#10040)") + expect(hash).toEqual('487548234b49bb93bb79ad89c7ac4a91ed6c0dc9') + + }) }) test('getCommitMessage', async () => { diff --git a/triage/lib/triage.ts b/triage/lib/triage.ts index 3996d85..02a13e3 100755 --- a/triage/lib/triage.ts +++ b/triage/lib/triage.ts @@ -2,14 +2,15 @@ import { fileURLToPath } from 'node:url' import { chalk, fs, path, question, $ } from 'zx' -import { consoleBoxen } from '@lib/boxen.js' -import { getUserLogin, pushBranch, pushNotes } from '@lib/github.js' -import { resIsYes, resolveRes } from '@lib/prompts.js' +import { consoleBoxen, separator } from '@lib/console_helpers.js' +import { cherryPickCommits, reportCommitsEligibleForCherryPick } from '@lib/cherry_pick.js' +import { pushBranch, pushNotes } from '@lib/github.js' +import { resIsYes } from '@lib/prompts.js' +import type { Commit, PrettyCommit, Range } from '@lib/types.js' import { unwrap } from '@lib/zx_helpers.js' import { getPrettyLine, getSymmetricDifference, resolveSymmetricDifference } from './symmetric_difference.js' -import { colors, separator } from './tokens.js' -import type { Commit, PrettyCommit, Range } from './types.js' +import { colors } from './colors.js' export async function triageRange(range: Range, { remote }: { remote: string }) { const key = await cache.getKey(range) @@ -36,16 +37,16 @@ export async function triageRange(range: Range, { remote }: { remote: string }) return } - reportCommitsEligibleForCherryPick(commitsEligibleForCherryPick, { range }) + reportCommitsEligibleForCherryPick(commitsEligibleForCherryPick) console.log(separator) await cherryPickCommits(commitsEligibleForCherryPick.toReversed(), { range }) console.log(separator) - const okToPushNotes = resIsYes(await question('Ok to push notes? [Y/n/o(pen)] > ')) + const okToPushNotes = resIsYes(await question('Ok to push notes? [Y/n] > ')) if (okToPushNotes) { await pushNotes(remote) } - const okToPushBranch = resIsYes(await question(`Ok to push ${range.to}? [Y/n/o(pen)] > `)) + const okToPushBranch = resIsYes(await question(`Ok to push ${range.to}? [Y/n] > `)) if (okToPushBranch) { await pushBranch(range.to, remote) } @@ -116,53 +117,3 @@ export function commitIsEligibleForCherryPick(commit: Commit, { range }: { range return true } - -function reportCommitsEligibleForCherryPick(commits: Commit[], { range }: { range: Range }) { - consoleBoxen(`🧮 ${commits.length} commits to triage`, commits.map(commit => commit.line).join('\n')) -} - -async function cherryPickCommits(commits: Commit[], { range }: { range: Range }) { - const login = await getUserLogin() - - for (let i = 0; i < commits.length; i++) { - const commit = commits[i] - consoleBoxen(`🧮 Triaging ${i + 1} of ${commits.length}`, commit.line) - - while (true) { - const res = resolveRes(await question('Ok to cherry pick? [Y/n/o(pen)] > ')) - - if (res === 'open') { - if (commit.url) { - await $`open ${commit.url}` - } else { - console.log("There's no PR associated with this commit") - } - continue - } else if (res === 'no') { - let res = await question('Add a note explaining why not > ') - res = `(${login}) ${res}` - await $`git notes add -m ${res} ${commit.hash}` - await $`git notes show ${commit.hash}` - console.log(`You can edit the note with \`git notes edit ${commit.hash}\``) - break - } - - try { - await $`git switch ${range.to}` - await $`git cherry-pick ${commit.hash}` - console.log() - console.log(chalk.green('🌸 Successfully cherry picked')) - break - } catch (error) { - console.log() - console.log(chalk.yellow("✋ Couldn't cleanly cherry pick. Resolve the conflicts and run `git cherry-pick --continue`")) - await question('Press anything to continue > ') - break - } - } - - console.log() - } - - consoleBoxen('🏁 Finish!', `All ${commits.length} commits have been triaged`) -} diff --git a/triage/run.ts b/triage/run.ts index a57b1dc..e4ed43c 100755 --- a/triage/run.ts +++ b/triage/run.ts @@ -1,6 +1,8 @@ -import { consoleBoxen } from '@lib/boxen.js' -import { setCwd } from '@lib/cwd.js' -import { CustomError } from '@lib/error.js' +import { chalk } from 'zx' + +import { consoleBoxen, separator } from '@lib/console_helpers.js' +import { setCwd } from '@lib/set_cwd.js' +import { CustomError } from '@lib/custom_error.js' import { assertBranchExists, assertWorkTreeIsClean, getRedwoodRemote } from '@lib/git.js' import { fetchNotes, pullBranch } from '@lib/github.js' @@ -9,17 +11,26 @@ import { triageRange } from "./lib/triage.js"; try { await setCwd() + + console.log(separator) await assertWorkTreeIsClean() + + console.log(separator) const remote = await getRedwoodRemote() + console.log(separator) const options = await getOptions() + + console.log(separator) await assertBranchExists(options.range.from) await assertBranchExists(options.range.to) + console.log(separator) await pullBranch(options.range.from, remote) await pullBranch(options.range.to, remote) await fetchNotes(remote) + console.log(separator) await triageRange(options.range, { remote }); } catch (error) { process.exitCode = 1;