From c3f6e9361842202be3bdf92f7f545b981fb8edfc Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 6 Aug 2026 11:32:03 +1000 Subject: [PATCH 1/3] AP-9940 # Added `oneblink-release audit-fix` command --- CHANGELOG.md | 5 + src/bin.ts | 28 ++++- src/createOrLinkPullRequest.ts | 77 ++++++++++++ src/resolveTicket.ts | 45 +++++++ src/startAuditFix.ts | 216 +++++++++++++++++++++++++++++++++ src/startUpdateDependents.ts | 78 +++++------- 6 files changed, 398 insertions(+), 51 deletions(-) create mode 100644 src/createOrLinkPullRequest.ts create mode 100644 src/resolveTicket.ts create mode 100644 src/startAuditFix.ts 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..815c4c3 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,22 @@ ${chalk.bold('Examples')} oneblink-release product oneblink-release product --name="Inappropriate Release Name" +${chalk.bold.blue('oneblink-release audit-fix [--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).`, +)} + + --ticket ........ Ticket to use as the branch name and commit prefix + (e.g. ON-4323). Will prompt if not supplied. + +${chalk.bold('Examples')} + + oneblink-release audit-fix + oneblink-release audit-fix --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 +108,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 +232,12 @@ async function run(): Promise { }) break } + case 'audit-fix': { + await startAuditFix({ + ticket: cli.flags.ticket, + }) + 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..459bc19 --- /dev/null +++ b/src/startAuditFix.ts @@ -0,0 +1,216 @@ +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, +}: { + ticket?: string +}) { + const ticket = await resolveTicket({ ticketFlag }) + const octokit = createPullRequestOctokit() + + console.log( + `Beginning npm audit fix across product repositories using branch "${ticket}"`, + ) + + const pullRequestUrls: string[] = [] + const fixedRepositories: string[] = [] + let createdAnyPullRequest = false + + 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 + } + }, + ) + + console.log( + boxen(chalk.green('npm audit fix complete!!!'), { + 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 { + 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, }: { From 02fcffcc6bddc4ca0f28cc620bfc220684431368 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 6 Aug 2026 11:41:46 +1000 Subject: [PATCH 2/3] AP-9940 # Added --force flag to audit-fix command --- src/bin.ts | 7 ++++++- src/startAuditFix.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 815c4c3..3a51195 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -35,7 +35,7 @@ ${chalk.bold('Examples')} oneblink-release product oneblink-release product --name="Inappropriate Release Name" -${chalk.bold.blue('oneblink-release audit-fix [--ticket]')} +${chalk.bold.blue('oneblink-release audit-fix [--force] [--ticket]')} ${chalk.grey( `Run "npm audit fix --package-lock-only" across each Product repository. Where @@ -43,13 +43,17 @@ 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]', @@ -235,6 +239,7 @@ async function run(): Promise { case 'audit-fix': { await startAuditFix({ ticket: cli.flags.ticket, + force: cli.flags.force, }) break } diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts index 459bc19..70d2f13 100644 --- a/src/startAuditFix.ts +++ b/src/startAuditFix.ts @@ -13,10 +13,12 @@ import resolveTicket from './resolveTicket.js' export default async function startAuditFix({ ticket: ticketFlag, + force = false, }: { ticket?: string + force?: boolean }) { - const ticket = await resolveTicket({ ticketFlag }) + const ticket = await resolveTicket({ ticketFlag, force }) const octokit = createPullRequestOctokit() console.log( From c0a4adebb50a4f63be1bc29f471363a8d0119f4c Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 6 Aug 2026 11:47:01 +1000 Subject: [PATCH 3/3] AP-9940 # Fixed missing partial success logs --- src/startAuditFix.ts | 216 +++++++++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 102 deletions(-) diff --git a/src/startAuditFix.ts b/src/startAuditFix.ts index 70d2f13..946bc32 100644 --- a/src/startAuditFix.ts +++ b/src/startAuditFix.ts @@ -28,135 +28,147 @@ export default async function startAuditFix({ const pullRequestUrls: string[] = [] const fixedRepositories: string[] = [] let createdAnyPullRequest = false + let completedSuccessfully = false - 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.`, + 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', ) - return - } + if (!(await fileExists(packageLockPath))) { + console.log( + `Skipping "${repositoryName}" as it does not contain a package-lock.json file.`, + ) + return + } - await runNpmAuditFix(repositoryWorkingDirectory) + await runNpmAuditFix(repositoryWorkingDirectory) - const packageLockChanged = await hasPackageLockChanges( - repositoryWorkingDirectory, - ) - if (!packageLockChanged) { - console.log( - `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, + const packageLockChanged = await hasPackageLockChanges( + repositoryWorkingDirectory, ) - return - } + if (!packageLockChanged) { + console.log( + `Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`, + ) + return + } - const commitMessage = `${ticket} # npm audit fix` + 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'], + ['checkout', '-b', ticket], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['add', 'package-lock.json'], repositoryWorkingDirectory, ) - } - await executeCommand( - 'git', - ['commit', '--message', commitMessage], - repositoryWorkingDirectory, - ) - await executeCommand( - 'git', - ['push', '-u', 'origin', ticket], - 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, + ) + } - 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 - } - }, - ) + await executeCommand( + 'git', + ['commit', '--message', commitMessage], + repositoryWorkingDirectory, + ) + await executeCommand( + 'git', + ['push', '-u', 'origin', ticket], + repositoryWorkingDirectory, + ) - console.log( - boxen(chalk.green('npm audit fix complete!!!'), { - padding: 1, - }), - ) + 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 + } + }, + ) - if (fixedRepositories.length) { + completedSuccessfully = true + } finally { console.log( boxen( - `The following repositories had fixes applied: - - ${fixedRepositories.join(` - `)}`, + chalk[completedSuccessfully ? 'green' : 'yellow']( + completedSuccessfully + ? 'npm audit fix complete!!!' + : 'npm audit fix stopped after an error', + ), { 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} + 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, - }, - ), - ) - } else { - console.log( - boxen(chalk.blue('No package-lock.json changes were produced.'), { - padding: 1, - }), - ) + }), + ) + } } }