diff --git a/README.md b/README.md index 51982b9..1825607 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ jobs: uses: dependabot/fetch-metadata@v1.2.1 with: alert-lookup: true + compat-lookup: true ``` Supported inputs are: @@ -33,7 +34,10 @@ Supported inputs are: - The `GITHUB_TOKEN` secret - Defaults to `${{ github.token }}` - `alert-lookup` (boolean) - - If `true`, then call populate the `alert-state`, `ghsa-id` and `cvss` outputs. + - If `true`, then populate the `alert-state`, `ghsa-id` and `cvss` outputs. + - Defaults to `false` +- `compat-lookup` (boolean) + - If `true`, then populate the `compatibility-score` output. - Defaults to `false` Subsequent actions will have access to the following outputs: @@ -62,6 +66,8 @@ Subsequent actions will have access to the following outputs: - If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the GHSA-ID of that alert. - `steps.dependabot-metadata.outputs.cvss` - If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the CVSS value of that alert (otherwise it contains 0). +- `steps.dependabot-metadata.outputs.compatibility-score` + - If this PR has a known compatibility score and `compat-lookup` is `true`, this contains the compatibility score (otherwise it contains 0). **Note:** These outputs will only be populated if the target Pull Request was opened by Dependabot and contains **only** Dependabot-created commits. diff --git a/action.yml b/action.yml index 105aecd..dbf07cb 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,10 @@ branding: inputs: alert-lookup: type: boolean - description: 'If true, then call populate the `alert-state`, `ghsa-id` and `cvss` outputs' + description: 'If true, then populate the `alert-state`, `ghsa-id` and `cvss` outputs' + compat-lookup: + type: boolean + description: 'If true, then populate the `compatibility-score` output' github-token: description: 'The GITHUB_TOKEN secret' default: ${{ github.token }} @@ -35,6 +38,8 @@ outputs: description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the GHSA-ID of that alert.' cvss: description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the CVSS value of that alert (otherwise it contains 0).' + compatibility-score: + description: 'If this PR has a known compatibility score and `compat-lookup` is `true`, this contains the compatibility score (otherwise it contains 0).' runs: using: 'node12' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index bf0735f..986ad14 100644 --- a/dist/index.js +++ b/dist/index.js @@ -8958,6 +8958,7 @@ function set(updatedDependencies) { const target = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.targetBranch; const prevVersion = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.prevVersion; const newVersion = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.newVersion; + const compatScore = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.compatScore; const alertState = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.alertState; const ghsaId = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.ghsaId; const cvss = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.cvss; @@ -8970,6 +8971,7 @@ function set(updatedDependencies) { core.info(`outputs.target-branch: ${target}`); core.info(`outputs.previous-version: ${prevVersion}`); core.info(`outputs.new-version: ${newVersion}`); + core.info(`outputs.compatibility-score: ${compatScore}`); core.info(`outputs.alert-state: ${alertState}`); core.info(`outputs.ghsa-id: ${ghsaId}`); core.info(`outputs.cvss: ${cvss}`); @@ -8983,6 +8985,7 @@ function set(updatedDependencies) { core.setOutput('target-branch', target); core.setOutput('previous-version', prevVersion); core.setOutput('new-version', newVersion); + core.setOutput('compatibility-score', compatScore); core.setOutput('alert-state', alertState); core.setOutput('ghsa-id', ghsaId); core.setOutput('cvss', cvss); @@ -9042,12 +9045,13 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", ({ value: true })); exports.parse = void 0; const YAML = __importStar(__nccwpck_require__(4603)); -function parse(commitMessage, branchName, mainBranch, lookup) { +function parse(commitMessage, branchName, mainBranch, lookup, getScore) { var _a, _b, _c, _d; return __awaiter(this, void 0, void 0, function* () { const bumpFragment = commitMessage.match(/^Bumps .* from (?\d[^ ]*) to (?\d[^ ]*)\.$/m); const yamlFragment = commitMessage.match(/^-{3}\n(?[\S|\s]*?)\n^\.{3}\n/m); const lookupFn = lookup !== null && lookup !== void 0 ? lookup : (() => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 })); + const scoreFn = getScore !== null && getScore !== void 0 ? getScore : (() => Promise.resolve(0)); if ((yamlFragment === null || yamlFragment === void 0 ? void 0 : yamlFragment.groups) && branchName.startsWith('dependabot')) { const data = YAML.parse(yamlFragment.groups.dependencies); // Since we are on the `dependabot` branch (9 letters), the 10th letter in the branch name is the delimiter @@ -9058,7 +9062,9 @@ function parse(commitMessage, branchName, mainBranch, lookup) { if (data['updated-dependencies']) { return yield Promise.all(data['updated-dependencies'].map((dependency, index) => __awaiter(this, void 0, void 0, function* () { const dirname = `/${chunks.slice(2, -1 * (1 + (dependency['dependency-name'].match(/\//g) || []).length)).join(delim) || ''}`; - return Object.assign({ dependencyName: dependency['dependency-name'], dependencyType: dependency['dependency-type'], updateType: dependency['update-type'], directory: dirname, packageEcosystem: chunks[1], targetBranch: mainBranch, prevVersion: index === 0 ? prev : '', newVersion: index === 0 ? next : '' }, yield lookupFn(dependency['dependency-name'], index === 0 ? prev : '', dirname)); + const lastVersion = index === 0 ? prev : ''; + const nextVersion = index === 0 ? next : ''; + return Object.assign({ dependencyName: dependency['dependency-name'], dependencyType: dependency['dependency-type'], updateType: dependency['update-type'], directory: dirname, packageEcosystem: chunks[1], targetBranch: mainBranch, prevVersion: lastVersion, newVersion: nextVersion, compatScore: yield scoreFn(dependency['dependency-name'], lastVersion, nextVersion, chunks[1]) }, yield lookupFn(dependency['dependency-name'], lastVersion, dirname)); }))); } } @@ -9127,9 +9133,13 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.trimSlashes = exports.getAlert = exports.getMessage = void 0; +exports.getCompatibility = exports.trimSlashes = exports.getAlert = exports.getMessage = void 0; const core = __importStar(__nccwpck_require__(2186)); +const https_1 = __importDefault(__nccwpck_require__(5687)); const DEPENDABOT_LOGIN = 'dependabot[bot]'; function getMessage(client, context) { var _a; @@ -9206,6 +9216,20 @@ function trimSlashes(value) { return value.replace(/^\/+/, '').replace(/\/+$/, ''); } exports.trimSlashes = trimSlashes; +function getCompatibility(name, oldVersion, newVersion, ecosystem) { + return __awaiter(this, void 0, void 0, function* () { + const svg = yield new Promise((resolve) => { + https_1.default.get(`https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=${name}&package-manager=${ecosystem}&previous-version=${oldVersion}&new-version=${newVersion}`, res => { + let data = ''; + res.on('data', chunk => { data += chunk.toString('utf8'); }); + res.on('end', () => { resolve(data); }); + }).on('error', () => { resolve(''); }); + }); + const scoreChunk = svg.match(/compatibility: (?<score>\d+)%<\/title>/m); + return (scoreChunk === null || scoreChunk === void 0 ? void 0 : scoreChunk.groups) ? parseInt(scoreChunk.groups.score) : 0; + }); +} +exports.getCompatibility = getCompatibility; /***/ }), @@ -9270,10 +9294,11 @@ function run() { if (core.getInput('alert-lookup')) { alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context); } + const scoreLookup = core.getInput('compat-lookup') ? verifiedCommits.getCompatibility : undefined; if (commitMessage) { // Parse metadata core.info('Parsing Dependabot metadata'); - const updatedDependencies = yield updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup); + const updatedDependencies = yield updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup, scoreLookup); if (updatedDependencies.length > 0) { output.set(updatedDependencies); } diff --git a/src/dependabot/output.test.ts b/src/dependabot/output.test.ts index 0c55825..825d620 100644 --- a/src/dependabot/output.test.ts +++ b/src/dependabot/output.test.ts @@ -18,6 +18,7 @@ const baseDependency = { targetBranch: '', prevVersion: '', newVersion: '', + compatScore: 0, alertState: '', ghsaId: '', cvss: 0 @@ -34,6 +35,7 @@ test('when given a single dependency it sets its values', async () => { targetBranch: 'main', prevVersion: '1.0.2', newVersion: '1.1.3-beta', + compatScore: 43, alertState: 'FIXED', ghsaId: 'VERY_LONG_ID', cvss: 4.6 @@ -56,6 +58,7 @@ test('when given a single dependency it sets its values', async () => { expect(core.setOutput).toBeCalledWith('target-branch', 'main') expect(core.setOutput).toBeCalledWith('previous-version', '1.0.2') expect(core.setOutput).toBeCalledWith('new-version', '1.1.3-beta') + expect(core.setOutput).toBeCalledWith('compatibility-score', 43) expect(core.setOutput).toBeCalledWith('alert-state', 'FIXED') expect(core.setOutput).toBeCalledWith('ghsa-id', 'VERY_LONG_ID') expect(core.setOutput).toBeCalledWith('cvss', 4.6) @@ -101,6 +104,7 @@ test('when given a multiple dependencies, it uses the highest values for types', expect(core.setOutput).toBeCalledWith('target-branch', '') expect(core.setOutput).toBeCalledWith('previous-version', '') expect(core.setOutput).toBeCalledWith('new-version', '') + expect(core.setOutput).toBeCalledWith('compatibility-score', 0) expect(core.setOutput).toBeCalledWith('alert-state', '') expect(core.setOutput).toBeCalledWith('ghsa-id', '') expect(core.setOutput).toBeCalledWith('cvss', 0) @@ -131,6 +135,7 @@ test('when the dependency has no update type', async () => { expect(core.setOutput).toBeCalledWith('target-branch', '') expect(core.setOutput).toBeCalledWith('previous-version', '') expect(core.setOutput).toBeCalledWith('new-version', '') + expect(core.setOutput).toBeCalledWith('compatibility-score', 0) expect(core.setOutput).toBeCalledWith('alert-state', '') expect(core.setOutput).toBeCalledWith('ghsa-id', '') expect(core.setOutput).toBeCalledWith('cvss', 0) @@ -174,6 +179,7 @@ test('when given a multiple dependencies, and some do not have update types', as expect(core.setOutput).toBeCalledWith('target-branch', '') expect(core.setOutput).toBeCalledWith('previous-version', '') expect(core.setOutput).toBeCalledWith('new-version', '') + expect(core.setOutput).toBeCalledWith('compatibility-score', 0) expect(core.setOutput).toBeCalledWith('alert-state', '') expect(core.setOutput).toBeCalledWith('ghsa-id', '') expect(core.setOutput).toBeCalledWith('cvss', 0) diff --git a/src/dependabot/output.ts b/src/dependabot/output.ts index 8dd5111..1add18f 100644 --- a/src/dependabot/output.ts +++ b/src/dependabot/output.ts @@ -26,6 +26,7 @@ export function set (updatedDependencies: Array<updatedDependency>): void { const target = firstDependency?.targetBranch const prevVersion = firstDependency?.prevVersion const newVersion = firstDependency?.newVersion + const compatScore = firstDependency?.compatScore const alertState = firstDependency?.alertState const ghsaId = firstDependency?.ghsaId const cvss = firstDependency?.cvss @@ -39,6 +40,7 @@ export function set (updatedDependencies: Array<updatedDependency>): void { core.info(`outputs.target-branch: ${target}`) core.info(`outputs.previous-version: ${prevVersion}`) core.info(`outputs.new-version: ${newVersion}`) + core.info(`outputs.compatibility-score: ${compatScore}`) core.info(`outputs.alert-state: ${alertState}`) core.info(`outputs.ghsa-id: ${ghsaId}`) core.info(`outputs.cvss: ${cvss}`) @@ -53,6 +55,7 @@ export function set (updatedDependencies: Array<updatedDependency>): void { core.setOutput('target-branch', target) core.setOutput('previous-version', prevVersion) core.setOutput('new-version', newVersion) + core.setOutput('compatibility-score', compatScore) core.setOutput('alert-state', alertState) core.setOutput('ghsa-id', ghsaId) core.setOutput('cvss', cvss) diff --git a/src/dependabot/update_metadata.test.ts b/src/dependabot/update_metadata.test.ts index 1b32595..b2af9d2 100644 --- a/src/dependabot/update_metadata.test.ts +++ b/src/dependabot/update_metadata.test.ts @@ -2,7 +2,8 @@ import * as updateMetadata from './update_metadata' test('it returns an empty array for a blank string', async () => { const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) - expect(updateMetadata.parse('', 'dependabot/nuget/coffee-rails', 'main', getAlert)).resolves.toEqual([]) + const getScore = async () => Promise.resolve(43) + expect(updateMetadata.parse('', 'dependabot/nuget/coffee-rails', 'main', getAlert, getScore)).resolves.toEqual([]) }) test('it returns an empty array for commit message with no dependabot yaml fragment', async () => { @@ -14,7 +15,8 @@ test('it returns an empty array for commit message with no dependabot yaml fragm Signed-off-by: dependabot[bot] <support@github.com>` const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) - expect(updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert)).resolves.toEqual([]) + const getScore = async () => Promise.resolve(43) + expect(updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert, getScore)).resolves.toEqual([]) }) test('it returns the updated dependency information when there is a yaml fragment', async () => { @@ -34,7 +36,8 @@ test('it returns the updated dependency information when there is a yaml fragmen 'Signed-off-by: dependabot[bot] <support@github.com>' const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) - const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert) + const getScore = async () => Promise.resolve(43) + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert, getScore) expect(updatedDependencies).toHaveLength(1) @@ -46,6 +49,7 @@ test('it returns the updated dependency information when there is a yaml fragmen expect(updatedDependencies[0].targetBranch).toEqual('main') expect(updatedDependencies[0].prevVersion).toEqual('4.0.1') expect(updatedDependencies[0].newVersion).toEqual('4.2.2') + expect(updatedDependencies[0].compatScore).toEqual(43) expect(updatedDependencies[0].alertState).toEqual('DISMISSED') expect(updatedDependencies[0].ghsaId).toEqual('GHSA-III-BBB') expect(updatedDependencies[0].cvss).toEqual(4.6) @@ -78,7 +82,15 @@ test('it supports multiple dependencies within a single fragment', async () => { return Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }) } - const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/main/coffee-rails', 'main', getAlert) + const getScore = async (name: string) => { + if (name === 'coffee-rails') { + return Promise.resolve(34) + } + + return Promise.resolve(0) + } + + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/main/coffee-rails', 'main', getAlert, getScore) expect(updatedDependencies).toHaveLength(2) @@ -90,6 +102,7 @@ test('it supports multiple dependencies within a single fragment', async () => { expect(updatedDependencies[0].targetBranch).toEqual('main') expect(updatedDependencies[0].prevVersion).toEqual('4.0.1') expect(updatedDependencies[0].newVersion).toEqual('4.2.2') + expect(updatedDependencies[0].compatScore).toEqual(34) expect(updatedDependencies[0].alertState).toEqual('DISMISSED') expect(updatedDependencies[0].ghsaId).toEqual('GHSA-III-BBB') expect(updatedDependencies[0].cvss).toEqual(4.6) @@ -101,7 +114,7 @@ test('it supports multiple dependencies within a single fragment', async () => { expect(updatedDependencies[1].packageEcosystem).toEqual('nuget') expect(updatedDependencies[1].targetBranch).toEqual('main') expect(updatedDependencies[1].prevVersion).toEqual('') - expect(updatedDependencies[1].newVersion).toEqual('') + expect(updatedDependencies[1].compatScore).toEqual(0) expect(updatedDependencies[1].alertState).toEqual('') expect(updatedDependencies[1].ghsaId).toEqual('') expect(updatedDependencies[1].cvss).toEqual(0) @@ -129,7 +142,7 @@ test('it only returns information within the first fragment if there are multipl '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' - const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot|nuget|coffee-rails', 'main', undefined) + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot|nuget|coffee-rails', 'main', undefined, undefined) expect(updatedDependencies).toHaveLength(1) @@ -141,6 +154,7 @@ test('it only returns information within the first fragment if there are multipl expect(updatedDependencies[0].targetBranch).toEqual('main') expect(updatedDependencies[0].prevVersion).toEqual('') expect(updatedDependencies[0].newVersion).toEqual('') + expect(updatedDependencies[0].compatScore).toEqual(0) expect(updatedDependencies[0].alertState).toEqual('') expect(updatedDependencies[0].ghsaId).toEqual('') expect(updatedDependencies[0].cvss).toEqual(0) @@ -162,7 +176,8 @@ test('it properly handles dependencies which contain slashes', async () => { 'Signed-off-by: dependabot[bot] <support@github.com>' const getAlert = async () => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }) - const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/rails/coffee', 'main', getAlert) + const getScore = async () => Promise.resolve(0) + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/rails/coffee', 'main', getAlert, getScore) expect(updatedDependencies).toHaveLength(1) @@ -174,6 +189,7 @@ test('it properly handles dependencies which contain slashes', async () => { expect(updatedDependencies[0].targetBranch).toEqual('main') expect(updatedDependencies[0].prevVersion).toEqual('') expect(updatedDependencies[0].newVersion).toEqual('') + expect(updatedDependencies[0].compatScore).toEqual(0) expect(updatedDependencies[0].alertState).toEqual('') expect(updatedDependencies[0].ghsaId).toEqual('') expect(updatedDependencies[0].cvss).toEqual(0) diff --git a/src/dependabot/update_metadata.ts b/src/dependabot/update_metadata.ts index ba57ce0..014f4dc 100644 --- a/src/dependabot/update_metadata.ts +++ b/src/dependabot/update_metadata.ts @@ -14,17 +14,23 @@ export interface updatedDependency extends dependencyAlert { packageEcosystem: string, targetBranch: string, prevVersion: string, - newVersion: string + newVersion: string, + compatScore: number } export interface alertLookup { (dependencyName: string, dependencyVersion: string, directory: string): Promise<dependencyAlert>; } -export async function parse (commitMessage: string, branchName: string, mainBranch: string, lookup?: alertLookup): Promise<Array<updatedDependency>> { +export interface scoreLookup { + (dependencyName: string, previousVersion: string, newVersion: string, ecosystem: string): Promise<number>; +} + +export async function parse (commitMessage: string, branchName: string, mainBranch: string, lookup?: alertLookup, getScore?: scoreLookup): Promise<Array<updatedDependency>> { const bumpFragment = commitMessage.match(/^Bumps .* from (?<from>\d[^ ]*) to (?<to>\d[^ ]*)\.$/m) const yamlFragment = commitMessage.match(/^-{3}\n(?<dependencies>[\S|\s]*?)\n^\.{3}\n/m) const lookupFn = lookup ?? (() => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 })) + const scoreFn = getScore ?? (() => Promise.resolve(0)) if (yamlFragment?.groups && branchName.startsWith('dependabot')) { const data = YAML.parse(yamlFragment.groups.dependencies) @@ -38,6 +44,8 @@ export async function parse (commitMessage: string, branchName: string, mainBran if (data['updated-dependencies']) { return await Promise.all(data['updated-dependencies'].map(async (dependency, index) => { const dirname = `/${chunks.slice(2, -1 * (1 + (dependency['dependency-name'].match(/\//g) || []).length)).join(delim) || ''}` + const lastVersion = index === 0 ? prev : '' + const nextVersion = index === 0 ? next : '' return { dependencyName: dependency['dependency-name'], dependencyType: dependency['dependency-type'], @@ -45,9 +53,10 @@ export async function parse (commitMessage: string, branchName: string, mainBran directory: dirname, packageEcosystem: chunks[1], targetBranch: mainBranch, - prevVersion: index === 0 ? prev : '', - newVersion: index === 0 ? next : '', - ...await lookupFn(dependency['dependency-name'], index === 0 ? prev : '', dirname) + prevVersion: lastVersion, + newVersion: nextVersion, + compatScore: await scoreFn(dependency['dependency-name'], lastVersion, nextVersion, chunks[1]), + ...await lookupFn(dependency['dependency-name'], lastVersion, dirname) } })) } diff --git a/src/dependabot/verified_commits.test.ts b/src/dependabot/verified_commits.test.ts index bc9c14c..cab6c82 100644 --- a/src/dependabot/verified_commits.test.ts +++ b/src/dependabot/verified_commits.test.ts @@ -2,7 +2,7 @@ import * as github from '@actions/github' import * as core from '@actions/core' import nock from 'nock' import { Context } from '@actions/github/lib/context' -import { getAlert, getMessage, trimSlashes } from './verified_commits' +import { getAlert, getMessage, trimSlashes, getCompatibility } from './verified_commits' beforeAll(() => { nock.disableNetConnect() @@ -177,6 +177,48 @@ test('trimSlashes should only trim slashes from both ends', () => { expect(trimSlashes('//a//b//c//')).toEqual('a//b//c') }) +const svgContents = `<svg width="132.9" height="20" viewBox="0 0 1329 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" role="img" aria-label="compatibility: 75%"> + <title>compatibility: 75% + + + + + + + + + + + + +` + +test('getCompatibility pulls out the score', async () => { + nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') + .reply(200, svgContents) + + expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(75) +}) + +test('getCompatibility fails gracefully', async () => { + nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') + .reply(200, '') + + expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(0) +}) + +test('getCompatibility handles errors', async () => { + nock('https://dependabot-badges.githubapp.com').get('/badges/compatibility_score?dependency-name=coffee-script&package-manager=npm_and_yarn&previous-version=2.1.3&new-version=2.2.0') + .reply(500, '') + + expect(await getCompatibility('coffee-script', '2.1.3', '2.2.0', 'npm_and_yarn')).toEqual(0) +}) + const mockGitHubClient = github.getOctokit('mock-token') function mockGitHubOtherContext (): Context { diff --git a/src/dependabot/verified_commits.ts b/src/dependabot/verified_commits.ts index b9685ba..7c3a692 100644 --- a/src/dependabot/verified_commits.ts +++ b/src/dependabot/verified_commits.ts @@ -2,6 +2,7 @@ import * as core from '@actions/core' import { GitHub } from '@actions/github/lib/utils' import { Context } from '@actions/github/lib/context' import type { dependencyAlert } from './update_metadata' +import https from 'https' const DEPENDABOT_LOGIN = 'dependabot[bot]' @@ -90,3 +91,16 @@ export async function getAlert (name: string, version: string, directory: string export function trimSlashes (value: string): string { return value.replace(/^\/+/, '').replace(/\/+$/, '') } + +export async function getCompatibility (name: string, oldVersion: string, newVersion: string, ecosystem: string): Promise { + const svg = await new Promise((resolve) => { + https.get(`https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=${name}&package-manager=${ecosystem}&previous-version=${oldVersion}&new-version=${newVersion}`, res => { + let data = '' + res.on('data', chunk => { data += chunk.toString('utf8') }) + res.on('end', () => { resolve(data) }) + }).on('error', () => { resolve('') }) + }) + + const scoreChunk = svg.match(/compatibility: (?<score>\d+)%<\/title>/m) + return scoreChunk?.groups ? parseInt(scoreChunk.groups.score) : 0 +} diff --git a/src/dry-run.ts b/src/dry-run.ts index 33406ef..b806d55 100755 --- a/src/dry-run.ts +++ b/src/dry-run.ts @@ -5,7 +5,7 @@ import * as dotenv from 'dotenv' import { Argv } from 'yargs' import { hideBin } from 'yargs/helpers' -import { getMessage, getAlert } from './dependabot/verified_commits' +import { getMessage, getAlert, getCompatibility } from './dependabot/verified_commits' import { parse } from './dependabot/update_metadata' import { getBranchNames, parseNwo } from './dependabot/util' @@ -53,7 +53,7 @@ async function check (args: any): Promise<void> { const branchNames = getBranchNames(newContext) const lookupFn = (name, version, directory) => getAlert(name, version, directory, githubClient, actionContext) - const updatedDependencies = await parse(commitMessage, branchNames.headName, branchNames.baseName, lookupFn) + const updatedDependencies = await parse(commitMessage, branchNames.headName, branchNames.baseName, lookupFn, getCompatibility) if (updatedDependencies.length > 0) { console.log('Updated dependencies:') diff --git a/src/main.test.ts b/src/main.test.ts index c986de2..c863b22 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -86,6 +86,9 @@ test('it sets the updated dependency as an output for subsequent actions', async jest.spyOn(dependabotCommits, 'getAlert').mockImplementation(jest.fn( () => Promise.resolve(mockAlert) )) + jest.spyOn(dependabotCommits, 'getCompatibility').mockImplementation(jest.fn( + () => Promise.resolve(34) + )) jest.spyOn(core, 'setOutput').mockImplementation(jest.fn()) await run() @@ -106,6 +109,7 @@ test('it sets the updated dependency as an output for subsequent actions', async targetBranch: 'main', prevVersion: '4.0.1', newVersion: '4.2.2', + compatScore: 0, alertState: '', ghsaId: '', cvss: 0 @@ -121,6 +125,7 @@ test('it sets the updated dependency as an output for subsequent actions', async expect(core.setOutput).toBeCalledWith('target-branch', 'main') expect(core.setOutput).toBeCalledWith('previous-version', '4.0.1') expect(core.setOutput).toBeCalledWith('new-version', '4.2.2') + expect(core.setOutput).toBeCalledWith('compatibility-score', 0) expect(core.setOutput).toBeCalledWith('alert-state', '') expect(core.setOutput).toBeCalledWith('ghsa-id', '') expect(core.setOutput).toBeCalledWith('cvss', 0) @@ -155,6 +160,9 @@ test('if there are multiple dependencies, it summarizes them', async () => { jest.spyOn(dependabotCommits, 'getAlert').mockImplementation(jest.fn( () => Promise.resolve(mockAlert) )) + jest.spyOn(dependabotCommits, 'getCompatibility').mockImplementation(jest.fn( + () => Promise.resolve(34) + )) jest.spyOn(core, 'setOutput').mockImplementation(jest.fn()) await run() @@ -175,6 +183,7 @@ test('if there are multiple dependencies, it summarizes them', async () => { targetBranch: 'trunk', prevVersion: '4.0.1', newVersion: '4.2.2', + compatScore: 34, alertState: '', ghsaId: '', cvss: 0 @@ -188,6 +197,7 @@ test('if there are multiple dependencies, it summarizes them', async () => { targetBranch: 'trunk', prevVersion: '', newVersion: '', + compatScore: 34, alertState: '', ghsaId: '', cvss: 0 @@ -203,6 +213,7 @@ test('if there are multiple dependencies, it summarizes them', async () => { expect(core.setOutput).toBeCalledWith('target-branch', 'trunk') expect(core.setOutput).toBeCalledWith('previous-version', '4.0.1') expect(core.setOutput).toBeCalledWith('new-version', '4.2.2') + expect(core.setOutput).toBeCalledWith('compatibility-score', 34) expect(core.setOutput).toBeCalledWith('alert-state', '') expect(core.setOutput).toBeCalledWith('ghsa-id', '') expect(core.setOutput).toBeCalledWith('cvss', 0) diff --git a/src/main.ts b/src/main.ts index 42bda69..3a53454 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,12 +28,13 @@ export async function run (): Promise<void> { if (core.getInput('alert-lookup')) { alertLookup = (name, version, directory) => verifiedCommits.getAlert(name, version, directory, githubClient, github.context) } + const scoreLookup = core.getInput('compat-lookup') ? verifiedCommits.getCompatibility : undefined if (commitMessage) { // Parse metadata core.info('Parsing Dependabot metadata') - const updatedDependencies = await updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup) + const updatedDependencies = await updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup, scoreLookup) if (updatedDependencies.length > 0) { output.set(updatedDependencies)