diff --git a/CHANGELOG.md b/CHANGELOG.md index c01179e..a45382b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `oneblink-release audit-fix` command to run `npm audit fix --package-lock-only` across product repositories, commit lockfile fixes on a shared ticket branch, and create pull requests when `GITHUB_OAUTH_TOKEN` is set (otherwise print create-PR URLs) +- `audit-fix` and `update-dependents` create GitHub pull requests via Octokit when `GITHUB_OAUTH_TOKEN` is set + ## [5.0.1] - 2026-08-04 ### Changed diff --git a/src/bin.ts b/src/bin.ts index e96c779..3a51195 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -14,6 +14,7 @@ import startProductRelease from './startProductRelease.js' import promptForReleaseName from './promptForReleaseName.js' import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' import startUpdateDependents from './startUpdateDependents.js' +import startAuditFix from './startAuditFix.js' import waitForNpmPackageVersion from './waitForNpmPackageVersion.js' import { readPackageUp } from 'read-package-up' @@ -34,6 +35,26 @@ ${chalk.bold('Examples')} oneblink-release product oneblink-release product --name="Inappropriate Release Name" +${chalk.bold.blue('oneblink-release audit-fix [--force] [--ticket]')} + +${chalk.grey( + `Run "npm audit fix --package-lock-only" across each Product repository. Where +package-lock.json changes, create a shared branch, commit, push, and create pull +requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`, +)} + + --force ......... Skip the ticket prompt. Requires --ticket. + + --ticket ........ Ticket to use as the branch name and commit prefix + (e.g. ON-4323). Will prompt if not supplied. + Required with --force. + +${chalk.bold('Examples')} + + oneblink-release audit-fix + oneblink-release audit-fix --ticket ON-4323 + oneblink-release audit-fix --force --ticket ON-4323 + ${chalk.bold.blue( 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]', )} @@ -91,7 +112,10 @@ ${chalk.bold.blue( 'oneblink-release update-dependents [--cwd path] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]', )} -${chalk.grey('Update all product code bases that depend on an NPM package.')} +${chalk.grey( + `Update all product code bases that depend on an NPM package. Creates pull +requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`, +)} --cwd .................................... Directory of the repository that is the dependency relative to the current working directory, defaults to the current @@ -212,6 +236,13 @@ async function run(): Promise { }) break } + case 'audit-fix': { + await startAuditFix({ + ticket: cli.flags.ticket, + force: cli.flags.force, + }) + break + } case 'product': { const releaseName = cli.flags.name || (await promptForReleaseName()) await startProductRelease({ releaseName }) diff --git a/src/createOrLinkPullRequest.ts b/src/createOrLinkPullRequest.ts new file mode 100644 index 0000000..38ba920 --- /dev/null +++ b/src/createOrLinkPullRequest.ts @@ -0,0 +1,77 @@ +import { Octokit } from '@octokit/rest' +import chalk from 'chalk' + +export function createPullRequestOctokit(): Octokit | undefined { + const token = process.env.GITHUB_OAUTH_TOKEN + if (!token) { + return undefined + } + + const octokit = new Octokit({ + auth: token, + }) + octokit.log.debug = () => undefined + return octokit +} + +export function getCreatePullRequestUrl( + repositoryName: string, + ticket: string, +): string { + return `https://github.com/oneblink/${repositoryName}/pull/new/${ticket}` +} + +export default async function createOrLinkPullRequest({ + octokit, + repositoryName, + ticket, + title, + body, +}: { + octokit: Octokit | undefined + repositoryName: string + ticket: string + title: string + body?: string +}): Promise<{ url: string; created: boolean }> { + const fallbackUrl = getCreatePullRequestUrl(repositoryName, ticket) + + if (!octokit) { + return { + url: fallbackUrl, + created: false, + } + } + + try { + const { data: repository } = await octokit.rest.repos.get({ + owner: 'oneblink', + repo: repositoryName, + }) + const { data } = await octokit.rest.pulls.create({ + owner: 'oneblink', + repo: repositoryName, + title, + head: ticket, + base: repository.default_branch, + body, + }) + + return { + url: data.html_url, + created: true, + } + } catch (error) { + console.warn( + chalk.yellow( + `Failed to create pull request for "${repositoryName}": ${ + error instanceof Error ? error.message : String(error) + }. Falling back to create-PR URL.`, + ), + ) + return { + url: fallbackUrl, + created: false, + } + } +} diff --git a/src/resolveTicket.ts b/src/resolveTicket.ts new file mode 100644 index 0000000..0f5a0c0 --- /dev/null +++ b/src/resolveTicket.ts @@ -0,0 +1,45 @@ +import enquirer from 'enquirer' + +export const TICKET_PATTERN = /^[a-z]{1,3}-\d+$/i + +const TICKET_VALIDATION_MESSAGE = + 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number' + +export default async function resolveTicket({ + ticketFlag, + force = false, +}: { + ticketFlag: string | undefined + force?: boolean +}): Promise { + if (ticketFlag) { + if (!TICKET_PATTERN.test(ticketFlag)) { + throw new Error(TICKET_VALIDATION_MESSAGE) + } + return ticketFlag.toUpperCase() + } + + if (force) { + throw new Error( + 'Cannot use "--force" without "--ticket" because all prompts are skipped.', + ) + } + + const { ticket } = await enquirer.prompt<{ + ticket: string + }>({ + type: 'input', + name: 'ticket', + message: `Ticket to associate with pull requests? (e.g. ON-4323, AP-4323, MS-4323)`, + required: true, + validate: (input) => { + if (!TICKET_PATTERN.test(input)) { + return TICKET_VALIDATION_MESSAGE + } + return true + }, + result: (input) => input.toUpperCase(), + }) + + return ticket +} diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts new file mode 100644 index 0000000..946bc32 --- /dev/null +++ b/src/startAuditFix.ts @@ -0,0 +1,230 @@ +import fs from 'fs/promises' +import path from 'path' +import boxen from 'boxen' +import chalk from 'chalk' +import { execa } from 'execa' +import enumerateProductRepositories from './enumerateProductRepositories.js' +import executeCommand from './executeCommand.js' +import wrapWithLoading from './wrapWithLoading.js' +import createOrLinkPullRequest, { + createPullRequestOctokit, +} from './createOrLinkPullRequest.js' +import resolveTicket from './resolveTicket.js' + +export default async function startAuditFix({ + ticket: ticketFlag, + force = false, +}: { + ticket?: string + force?: boolean +}) { + const ticket = await resolveTicket({ ticketFlag, force }) + const octokit = createPullRequestOctokit() + + console.log( + `Beginning npm audit fix across product repositories using branch "${ticket}"`, + ) + + const pullRequestUrls: string[] = [] + const fixedRepositories: string[] = [] + let createdAnyPullRequest = false + let completedSuccessfully = false + + try { + await enumerateProductRepositories( + async ({ productRepository, repositoryWorkingDirectory }) => { + const { repositoryName, type } = productRepository + + if (type === 'NUGET') { + console.log( + `Skipping "${repositoryName}" as NuGet repositories do not support npm audit fix.`, + ) + return + } + + const packageLockPath = path.join( + repositoryWorkingDirectory, + 'package-lock.json', + ) + if (!(await fileExists(packageLockPath))) { + console.log( + `Skipping "${repositoryName}" as it does not contain a package-lock.json file.`, + ) + return + } + + await runNpmAuditFix(repositoryWorkingDirectory) + + const packageLockChanged = await hasPackageLockChanges( + repositoryWorkingDirectory, + ) + if (!packageLockChanged) { + console.log( + `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, + ) + return + } + + const commitMessage = `${ticket} # npm audit fix` + + await executeCommand( + 'git', + ['checkout', '-b', ticket], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['add', 'package-lock.json'], + repositoryWorkingDirectory, + ) + + // npm audit fix can also update package.json when dependency ranges change + const packageJsonChanged = await hasFileChanges( + repositoryWorkingDirectory, + 'package.json', + ) + if (packageJsonChanged) { + await executeCommand( + 'git', + ['add', 'package.json'], + repositoryWorkingDirectory, + ) + } + + await executeCommand( + 'git', + ['commit', '--message', commitMessage], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['push', '-u', 'origin', ticket], + repositoryWorkingDirectory, + ) + + const pullRequest = await createOrLinkPullRequest({ + octokit, + repositoryName, + ticket, + title: commitMessage, + body: 'Automated `npm audit fix`.', + }) + + fixedRepositories.push(repositoryName) + pullRequestUrls.push(pullRequest.url) + if (pullRequest.created) { + createdAnyPullRequest = true + } + }, + ) + + completedSuccessfully = true + } finally { + console.log( + boxen( + chalk[completedSuccessfully ? 'green' : 'yellow']( + completedSuccessfully + ? 'npm audit fix complete!!!' + : 'npm audit fix stopped after an error', + ), + { + padding: 1, + }, + ), + ) + + if (fixedRepositories.length) { + console.log( + boxen( + `The following repositories had fixes applied: + + ${fixedRepositories.join(` + `)}`, + { + padding: 1, + }, + ), + ) + } + + if (pullRequestUrls.length) { + const pullRequestHeading = createdAnyPullRequest + ? 'The following Pull Requests were created:' + : 'The following Pull Requests can be created:' + console.log( + boxen( + `${pullRequestHeading} + + ${pullRequestUrls.join(` + `)}`, + { + padding: 1, + }, + ), + ) + } else if (completedSuccessfully) { + console.log( + boxen(chalk.blue('No package-lock.json changes were produced.'), { + padding: 1, + }), + ) + } + } +} + +async function runNpmAuditFix(cwd: string) { + const log = '"npm audit fix --package-lock-only --audit-level=none"' + return await wrapWithLoading( + { + startText: `Running ${log}`, + failText: `Failed to run ${log}`, + }, + async (spinner) => { + // npm audit fix exits non-zero when vulnerabilities remain after applying fixes + const result = await execa( + 'npm', + ['audit', 'fix', '--package-lock-only', '--audit-level=none'], + { + cwd, + reject: false, + }, + ) + + if (result.exitCode !== 0 && !(await hasPackageLockChanges(cwd))) { + spinner.fail(`Failed to run ${log}`) + throw new Error( + result.stderr || + result.stdout || + `npm audit fix --package-lock-only --audit-level=none failed with exit code ${result.exitCode}`, + ) + } + + spinner.succeed(`Ran ${log}`) + return result + }, + ) +} + +async function hasPackageLockChanges(cwd: string): Promise { + return await hasFileChanges(cwd, 'package-lock.json') +} + +async function hasFileChanges(cwd: string, fileName: string): Promise { + const { stdout } = await execa( + 'git', + ['status', '--porcelain', '--', fileName], + { + cwd, + }, + ) + return Boolean(stdout.trim()) +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath) + return true + } catch { + return false + } +} diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index 8558a18..5373f82 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -26,14 +26,16 @@ import { getRepositoriesNeedingDependencyUpdates, ScannedProductRepository, } from './updateDependentsPlanning.js' +import createOrLinkPullRequest, { + createPullRequestOctokit, +} from './createOrLinkPullRequest.js' +import resolveTicket from './resolveTicket.js' type RetainedClone = { repositoryWorkingDirectory: string removeRepositoryWorkingDirectory: () => Promise } -const TICKET_PATTERN = /^[a-z]{1,3}-\d+$/i - export default async function startUpdateDependents({ cwd, force = false, @@ -129,7 +131,9 @@ export default async function startUpdateDependents({ ]) const releasedRepositoryNames = new Set() const releasedPackageSummaries: string[] = [] - const createPullRequestUrls: string[] = [] + const pullRequestUrls: string[] = [] + let createdAnyPullRequest = false + const octokit = createPullRequestOctokit() for (const intermediate of sortedIntermediates) { const downstreamRepositoryNames = getDownstreamRepositoryNames({ @@ -301,6 +305,11 @@ export default async function startUpdateDependents({ continue } + const commitMessage = buildDependencyBumpCommitMessage({ + ticket, + bumpedPackageNames, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) await commitDependencyUpdates({ cwd: repositoryWorkingDirectory, ticket, @@ -312,9 +321,16 @@ export default async function startUpdateDependents({ ['push', '-u', 'origin', ticket], repositoryWorkingDirectory, ) - createPullRequestUrls.push( - `https://github.com/oneblink/${candidate.productRepository.repositoryName}/pull/new/${ticket}`, - ) + const pullRequest = await createOrLinkPullRequest({ + octokit, + repositoryName: candidate.productRepository.repositoryName, + ticket, + title: commitMessage, + }) + pullRequestUrls.push(pullRequest.url) + if (pullRequest.created) { + createdAnyPullRequest = true + } } if (releasedPackageSummaries.length) { @@ -331,12 +347,15 @@ export default async function startUpdateDependents({ ) } - if (createPullRequestUrls.length) { + if (pullRequestUrls.length) { + const pullRequestHeading = createdAnyPullRequest + ? 'The following Pull Requests were created:' + : 'The following Pull Requests can be created:' console.log( boxen( - `The following Pull Requests can be created: + `${pullRequestHeading} - ${createPullRequestUrls.join(` + ${pullRequestUrls.join(` `)}`, { padding: 1, @@ -349,47 +368,6 @@ export default async function startUpdateDependents({ } } -async function resolveTicket({ - ticketFlag, - force, -}: { - ticketFlag: string | undefined - force: boolean -}): Promise { - if (ticketFlag) { - if (!TICKET_PATTERN.test(ticketFlag)) { - throw new Error( - 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number', - ) - } - return ticketFlag.toUpperCase() - } - - if (force) { - throw new Error( - 'Cannot use "--force" without "--ticket" because all prompts are skipped.', - ) - } - - const { ticket } = await enquirer.prompt<{ - ticket: string - }>({ - type: 'input', - name: 'ticket', - message: `Ticket to associate with pull requests? (e.g. ON-4323, AP-4323, MS-4323)`, - required: true, - validate: (input) => { - if (!TICKET_PATTERN.test(input)) { - return 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number' - } - return true - }, - result: (input) => input.toUpperCase(), - }) - - return ticket -} - async function resolveIsUpdatingTypes({ force, }: {