diff --git a/README.md b/README.md index 27e04882..933ed7b5 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,21 @@ jobs: uses: dependabot/fetch-metadata@v1.1.1 with: github-token: "${{ secrets.GITHUB_TOKEN }}" + alert-lookup: true + compat-lookup: true ``` +Supported inputs are: + +- `github-token` (REQUIRED string) + - The `GITHUB_TOKEN` secret +- `alert-lookup` (boolean) + - 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: - `steps.dependabot-metadata.outputs.dependency-names` @@ -43,6 +56,18 @@ Subsequent actions will have access to the following outputs: - The `package-ecosystem` configuration that was used by dependabot for this updated Dependency. - `steps.dependabot-metadata.outputs.target-branch` - The `target-branch` configuration that was used by dependabot for this updated Dependency. +- `steps.dependabot-metadata.outputs.previous-version` + - The version that this PR updates the dependency from. +- `steps.dependabot-metadata.outputs.new-version` + - The version that this PR updates the dependency to. +- `steps.dependabot-metadata.outputs.alert-state` + - If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the current state of that alert (OPEN, FIXED or DISMISSED). +- `steps.dependabot-metadata.outputs.ghsa-id` + - 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 fe5bdd61..80fa9cb6 100644 --- a/action.yml +++ b/action.yml @@ -4,6 +4,12 @@ branding: icon: 'search' color: 'blue' inputs: + alert-lookup: + type: boolean + 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' required: true @@ -22,6 +28,18 @@ outputs: description: 'The `package-ecosystem` configuration that was used by dependabot for this updated Dependency.' target-branch: description: 'The `target-branch` configuration that was used by dependabot for this updated Dependency.' + previous-version: + description: 'The version that this PR updates the dependency from.' + new-version: + description: 'The version that this PR updates the dependency to.' + alert-state: + description: 'If this PR is associated with a security alert and `alert-lookup` is `true`, this contains the current state of that alert (OPEN, FIXED or DISMISSED).' + ghsa-id: + 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 1830dd7f..4a9adaa3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -8956,6 +8956,12 @@ function set(updatedDependencies) { const directory = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.directory; const ecosystem = firstDependency === null || firstDependency === void 0 ? void 0 : firstDependency.packageEcosystem; 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; core.startGroup(`Outputting metadata for ${(0, pluralize_1.default)('updated dependency', updatedDependencies.length, true)}`); core.info(`outputs.dependency-names: ${dependencyNames}`); core.info(`outputs.dependency-type: ${dependencyType}`); @@ -8963,6 +8969,12 @@ function set(updatedDependencies) { core.info(`outputs.directory: ${directory}`); core.info(`outputs.package-ecosystem: ${ecosystem}`); 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}`); core.endGroup(); core.setOutput('updated-dependencies-json', updatedDependencies); core.setOutput('dependency-names', dependencyNames); @@ -8971,6 +8983,12 @@ function set(updatedDependencies) { core.setOutput('directory', directory); core.setOutput('package-ecosystem', ecosystem); 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); } exports.set = set; function maxDependencyTypes(updatedDependencies) { @@ -9015,31 +9033,43 @@ var __importStar = (this && this.__importStar) || function (mod) { __setModuleDefault(result, mod); return result; }; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.parse = void 0; const YAML = __importStar(__nccwpck_require__(4603)); -function parse(commitMessage, branchName, mainBranch) { - const yamlFragment = commitMessage.match(/^-{3}\n(?[\S|\s]*?)\n^\.{3}\n/m); - 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 - const delim = branchName[10]; - const chunks = branchName.split(delim); - const dirname = chunks.slice(2, -1).join(delim) || '/'; - if (data['updated-dependencies']) { - return data['updated-dependencies'].map(dependency => { - return { - dependencyName: dependency['dependency-name'], - dependencyType: dependency['dependency-type'], - updateType: dependency['update-type'], - directory: dirname, - packageEcosystem: chunks[1], - targetBranch: mainBranch - }; - }); +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 + const delim = branchName[10]; + const chunks = branchName.split(delim); + const prev = (_b = (_a = bumpFragment === null || bumpFragment === void 0 ? void 0 : bumpFragment.groups) === null || _a === void 0 ? void 0 : _a.from) !== null && _b !== void 0 ? _b : ''; + const next = (_d = (_c = bumpFragment === null || bumpFragment === void 0 ? void 0 : bumpFragment.groups) === null || _c === void 0 ? void 0 : _c.to) !== null && _d !== void 0 ? _d : ''; + 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) || ''}`; + 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)); + }))); + } } - } - return []; + return Promise.resolve([]); + }); } exports.parse = parse; @@ -9103,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.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; @@ -9151,6 +9185,59 @@ function warnOtherCommits() { "Try using '@dependabot rebase' to remove merge commits or '@dependabot recreate' to remove " + 'any non-Dependabot changes.'); } +function getAlert(name, version, directory, client, context) { + var _a, _b, _c, _d, _e; + return __awaiter(this, void 0, void 0, function* () { + const alerts = yield client.graphql(` + { + repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") { + vulnerabilityAlerts(first: 100) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + } + } + }`); + const nodes = (_b = (_a = alerts === null || alerts === void 0 ? void 0 : alerts.repository) === null || _a === void 0 ? void 0 : _a.vulnerabilityAlerts) === null || _b === void 0 ? void 0 : _b.nodes; + const found = nodes.find(a => (version === '' || a.vulnerableRequirements === `= ${version}`) && + trimSlashes(a.vulnerableManifestPath) === `${trimSlashes(directory)}/${a.vulnerableManifestFilename}` && + a.securityVulnerability.package.name === name); + return { + alertState: (_c = found === null || found === void 0 ? void 0 : found.state) !== null && _c !== void 0 ? _c : '', + ghsaId: (_d = found === null || found === void 0 ? void 0 : found.securityAdvisory.ghsaId) !== null && _d !== void 0 ? _d : '', + cvss: (_e = found === null || found === void 0 ? void 0 : found.securityAdvisory.cvss.score) !== null && _e !== void 0 ? _e : 0.0 + }; + }); +} +exports.getAlert = getAlert; +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; /***/ }), @@ -9211,10 +9298,15 @@ function run() { // Validate the job const commitMessage = yield verifiedCommits.getMessage(githubClient, github.context); const branchNames = util.getBranchNames(github.context); + let alertLookup; + 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 = updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName); + 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 83e5ba2b..825d620a 100644 --- a/src/dependabot/output.test.ts +++ b/src/dependabot/output.test.ts @@ -9,6 +9,21 @@ beforeEach(() => { jest.spyOn(core, 'startGroup').mockImplementation(jest.fn()) }) +const baseDependency = { + dependencyName: '', + dependencyType: '', + updateType: '', + directory: '', + packageEcosystem: '', + targetBranch: '', + prevVersion: '', + newVersion: '', + compatScore: 0, + alertState: '', + ghsaId: '', + cvss: 0 +} + test('when given a single dependency it sets its values', async () => { const updatedDependencies = [ { @@ -17,7 +32,13 @@ test('when given a single dependency it sets its values', async () => { updateType: 'version-update:semver-minor', directory: 'wwwroot', packageEcosystem: 'nuget', - targetBranch: 'main' + targetBranch: 'main', + prevVersion: '1.0.2', + newVersion: '1.1.3-beta', + compatScore: 43, + alertState: 'FIXED', + ghsaId: 'VERY_LONG_ID', + cvss: 4.6 } ] @@ -35,41 +56,39 @@ test('when given a single dependency it sets its values', async () => { expect(core.setOutput).toBeCalledWith('directory', 'wwwroot') expect(core.setOutput).toBeCalledWith('package-ecosystem', 'nuget') 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) }) test('when given a multiple dependencies, it uses the highest values for types', async () => { const updatedDependencies = [ { + ...baseDependency, dependencyName: 'rspec', dependencyType: 'direct:development', - updateType: 'version-update:semver-minor', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-minor' }, { + ...baseDependency, dependencyName: 'coffee-rails', dependencyType: 'indirect', - updateType: 'version-update:semver-minor', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-minor' }, { + ...baseDependency, dependencyName: 'coffeescript', dependencyType: 'indirect', - updateType: 'version-update:semver-major', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-major' }, { + ...baseDependency, dependencyName: 'rspec-coffeescript', dependencyType: 'indirect', - updateType: 'version-update:semver-patch', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-patch' } ] @@ -83,17 +102,20 @@ test('when given a multiple dependencies, it uses the highest values for types', expect(core.setOutput).toBeCalledWith('directory', '') expect(core.setOutput).toBeCalledWith('package-ecosystem', '') 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) }) test('when the dependency has no update type', async () => { const updatedDependencies = [ { + ...baseDependency, dependencyName: 'coffee-rails', - dependencyType: 'direct:production', - updateType: '', - directory: '', - packageEcosystem: '', - targetBranch: '' + dependencyType: 'direct:production' } ] @@ -111,41 +133,37 @@ test('when the dependency has no update type', async () => { expect(core.setOutput).toBeCalledWith('directory', '') expect(core.setOutput).toBeCalledWith('package-ecosystem', '') 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) }) test('when given a multiple dependencies, and some do not have update types', async () => { const updatedDependencies = [ { + ...baseDependency, dependencyName: 'rspec', - dependencyType: 'direct:development', - updateType: '', - directory: '', - packageEcosystem: '', - targetBranch: '' + dependencyType: 'direct:development' }, { + ...baseDependency, dependencyName: 'coffee-rails', dependencyType: 'indirect', - updateType: 'version-update:semver-minor', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-minor' }, { + ...baseDependency, dependencyName: 'coffeescript', - dependencyType: 'indirect', - updateType: '', - directory: '', - packageEcosystem: '', - targetBranch: '' + dependencyType: 'indirect' }, { + ...baseDependency, dependencyName: 'rspec-coffeescript', dependencyType: 'indirect', - updateType: 'version-update:semver-patch', - directory: '', - packageEcosystem: '', - targetBranch: '' + updateType: 'version-update:semver-patch' } ] @@ -159,4 +177,10 @@ test('when given a multiple dependencies, and some do not have update types', as expect(core.setOutput).toBeCalledWith('directory', '') expect(core.setOutput).toBeCalledWith('package-ecosystem', '') 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 6f851d09..1add18fc 100644 --- a/src/dependabot/output.ts +++ b/src/dependabot/output.ts @@ -24,6 +24,12 @@ export function set (updatedDependencies: Array<updatedDependency>): void { const directory = firstDependency?.directory const ecosystem = firstDependency?.packageEcosystem 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 core.startGroup(`Outputting metadata for ${Pluralize('updated dependency', updatedDependencies.length, true)}`) core.info(`outputs.dependency-names: ${dependencyNames}`) @@ -32,6 +38,12 @@ export function set (updatedDependencies: Array<updatedDependency>): void { core.info(`outputs.directory: ${directory}`) core.info(`outputs.package-ecosystem: ${ecosystem}`) 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}`) core.endGroup() core.setOutput('updated-dependencies-json', updatedDependencies) @@ -41,6 +53,12 @@ export function set (updatedDependencies: Array<updatedDependency>): void { core.setOutput('directory', directory) core.setOutput('package-ecosystem', ecosystem) 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) } function maxDependencyTypes (updatedDependencies: Array<updatedDependency>): string { diff --git a/src/dependabot/update_metadata.test.ts b/src/dependabot/update_metadata.test.ts index 844ed2a9..b2af9d2d 100644 --- a/src/dependabot/update_metadata.test.ts +++ b/src/dependabot/update_metadata.test.ts @@ -1,7 +1,9 @@ import * as updateMetadata from './update_metadata' test('it returns an empty array for a blank string', async () => { - expect(updateMetadata.parse('', 'dependabot/nuget/feature1', 'main')).toEqual([]) + const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) + 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 () => { @@ -12,7 +14,9 @@ test('it returns an empty array for commit message with no dependabot yaml fragm Signed-off-by: dependabot[bot] <support@github.com>` - expect(updateMetadata.parse(commitMessage, 'dependabot/nuget/feature1', 'main')).toEqual([]) + const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) + 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 () => { @@ -31,7 +35,9 @@ test('it returns the updated dependency information when there is a yaml fragmen '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' - const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot/nuget/feature1', 'main') + const getAlert = async () => Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) + const getScore = async () => Promise.resolve(43) + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/coffee-rails', 'main', getAlert, getScore) expect(updatedDependencies).toHaveLength(1) @@ -41,6 +47,12 @@ test('it returns the updated dependency information when there is a yaml fragmen expect(updatedDependencies[0].directory).toEqual('/') expect(updatedDependencies[0].packageEcosystem).toEqual('nuget') 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) }) test('it supports multiple dependencies within a single fragment', async () => { @@ -62,28 +74,54 @@ test('it supports multiple dependencies within a single fragment', async () => { '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' - const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot/nuget/api/main/feature1', 'main') + const getAlert = async (name: string) => { + if (name === 'coffee-rails') { + return Promise.resolve({ alertState: 'DISMISSED', ghsaId: 'GHSA-III-BBB', cvss: 4.6 }) + } + + return Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }) + } + + 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) expect(updatedDependencies[0].dependencyName).toEqual('coffee-rails') expect(updatedDependencies[0].dependencyType).toEqual('direct:production') expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor') - expect(updatedDependencies[0].directory).toEqual('api/main') + expect(updatedDependencies[0].directory).toEqual('/api/main') expect(updatedDependencies[0].packageEcosystem).toEqual('nuget') 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) expect(updatedDependencies[1].dependencyName).toEqual('coffeescript') expect(updatedDependencies[1].dependencyType).toEqual('indirect') expect(updatedDependencies[1].updateType).toEqual('version-update:semver-patch') - expect(updatedDependencies[1].directory).toEqual('api/main') + expect(updatedDependencies[1].directory).toEqual('/api/main') expect(updatedDependencies[1].packageEcosystem).toEqual('nuget') expect(updatedDependencies[1].targetBranch).toEqual('main') + expect(updatedDependencies[1].prevVersion).toEqual('') + expect(updatedDependencies[1].compatScore).toEqual(0) + expect(updatedDependencies[1].alertState).toEqual('') + expect(updatedDependencies[1].ghsaId).toEqual('') + expect(updatedDependencies[1].cvss).toEqual(0) }) test('it only returns information within the first fragment if there are multiple yaml documents', async () => { const commitMessage = - 'Bumps [coffee-rails](https://github.com/rails/coffee-rails) from 4.0.1 to 4.2.2.\n' + '- [Release notes](https://github.com/rails/coffee-rails/releases)\n' + '- [Changelog](https://github.com/rails/coffee-rails/blob/master/CHANGELOG.md)\n' + '- [Commits](rails/coffee-rails@v4.0.1...v4.2.2)\n' + @@ -104,14 +142,55 @@ test('it only returns information within the first fragment if there are multipl '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' - const updatedDependencies = updateMetadata.parse(commitMessage, 'dependabot|nuget|api|feature1', 'main') + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot|nuget|coffee-rails', 'main', undefined, undefined) expect(updatedDependencies).toHaveLength(1) expect(updatedDependencies[0].dependencyName).toEqual('coffee-rails') expect(updatedDependencies[0].dependencyType).toEqual('direct:production') expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor') - expect(updatedDependencies[0].directory).toEqual('api') + expect(updatedDependencies[0].directory).toEqual('/') + expect(updatedDependencies[0].packageEcosystem).toEqual('nuget') + 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) +}) + +test('it properly handles dependencies which contain slashes', async () => { + const commitMessage = + '- [Release notes](https://github.com/rails/coffee/releases)\n' + + '- [Changelog](https://github.com/rails/coffee/blob/master/CHANGELOG.md)\n' + + '- [Commits](rails/coffee@v4.0.1...v4.2.2)\n' + + '\n' + + '---\n' + + 'updated-dependencies:\n' + + '- dependency-name: rails/coffee\n' + + ' dependency-type: direct:production\n' + + ' update-type: version-update:semver-minor\n' + + '...\n' + + '\n' + + 'Signed-off-by: dependabot[bot] <support@github.com>' + + const getAlert = async () => Promise.resolve({ alertState: '', ghsaId: '', cvss: 0 }) + const getScore = async () => Promise.resolve(0) + const updatedDependencies = await updateMetadata.parse(commitMessage, 'dependabot/nuget/api/rails/coffee', 'main', getAlert, getScore) + + expect(updatedDependencies).toHaveLength(1) + + expect(updatedDependencies[0].dependencyName).toEqual('rails/coffee') + expect(updatedDependencies[0].dependencyType).toEqual('direct:production') + expect(updatedDependencies[0].updateType).toEqual('version-update:semver-minor') + expect(updatedDependencies[0].directory).toEqual('/api') expect(updatedDependencies[0].packageEcosystem).toEqual('nuget') 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 36ac2ced..014f4dca 100644 --- a/src/dependabot/update_metadata.ts +++ b/src/dependabot/update_metadata.ts @@ -1,16 +1,36 @@ import * as YAML from 'yaml' -export interface updatedDependency { +export interface dependencyAlert { + alertState: string, + ghsaId: string, + cvss: number +} + +export interface updatedDependency extends dependencyAlert { dependencyName: string, dependencyType: string, updateType: string, directory: string, packageEcosystem: string, - targetBranch: string + targetBranch: string, + prevVersion: string, + newVersion: string, + compatScore: number +} + +export interface alertLookup { + (dependencyName: string, dependencyVersion: string, directory: string): Promise<dependencyAlert>; +} + +export interface scoreLookup { + (dependencyName: string, previousVersion: string, newVersion: string, ecosystem: string): Promise<number>; } -export function parse (commitMessage: string, branchName: string, mainBranch: string): Array<updatedDependency> { +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) @@ -18,21 +38,29 @@ export function parse (commitMessage: string, branchName: string, mainBranch: st // Since we are on the `dependabot` branch (9 letters), the 10th letter in the branch name is the delimiter const delim = branchName[10] const chunks = branchName.split(delim) - const dirname = chunks.slice(2, -1).join(delim) || '/' + const prev = bumpFragment?.groups?.from ?? '' + const next = bumpFragment?.groups?.to ?? '' if (data['updated-dependencies']) { - return data['updated-dependencies'].map(dependency => { + 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'], updateType: dependency['update-type'], directory: dirname, packageEcosystem: chunks[1], - targetBranch: mainBranch + targetBranch: mainBranch, + prevVersion: lastVersion, + newVersion: nextVersion, + compatScore: await scoreFn(dependency['dependency-name'], lastVersion, nextVersion, chunks[1]), + ...await lookupFn(dependency['dependency-name'], lastVersion, dirname) } - }) + })) } } - return [] + return Promise.resolve([]) } diff --git a/src/dependabot/verified_commits.test.ts b/src/dependabot/verified_commits.test.ts index 41e8f95c..84fed674 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 { getMessage } from './verified_commits' +import { getAlert, getMessage, trimSlashes, getCompatibility } from './verified_commits' beforeAll(() => { nock.disableNetConnect() @@ -130,6 +130,112 @@ test('it returns the commit message for a PR authored exclusively by Dependabot expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toEqual('Bump lodash from 1.0.0 to 2.0.0') }) +const query = '{"query":"\\n {\\n repository(owner: \\"dependabot\\", name: \\"dependabot\\") { \\n vulnerabilityAlerts(first: 100) {\\n nodes {\\n vulnerableManifestFilename\\n vulnerableManifestPath\\n vulnerableRequirements\\n state\\n securityVulnerability { \\n package { name } \\n }\\n securityAdvisory { \\n cvss { score }\\n ghsaId \\n }\\n }\\n }\\n }\\n }"}' + +const response = { + data: { + repository: { + vulnerabilityAlerts: { + nodes: [ + { + vulnerableManifestFilename: 'package.json', + vulnerableManifestPath: 'wwwroot/package.json', + vulnerableRequirements: '= 4.0.1', + state: 'DISMISSED', + securityVulnerability: { package: { name: 'coffee-script' } }, + securityAdvisory: { cvss: { score: 4.5 }, ghsaId: 'FOO' } + } + ] + } + } + } +} + +test('it returns the alert state if it matches all 3', async () => { + nock('https://api.github.com').post('/graphql', query) + .reply(200, response) + + expect(await getAlert('coffee-script', '4.0.1', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) +}) + +test('it returns the alert state if it matches 2 and the version is blank', async () => { + nock('https://api.github.com').post('/graphql', query) + .reply(200, response) + + expect(await getAlert('coffee-script', '', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: 'DISMISSED', cvss: 4.5, ghsaId: 'FOO' }) +}) + +test('it returns default if it does not match the version', async () => { + nock('https://api.github.com').post('/graphql', query) + .reply(200, response) + + expect(await getAlert('coffee-script', '4.0.2', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) +}) + +test('it returns default if it does not match the directory', async () => { + nock('https://api.github.com').post('/graphql', query) + .reply(200, response) + + expect(await getAlert('coffee-script', '4.0.1', '/', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) +}) + +test('it returns default if it does not match the name', async () => { + nock('https://api.github.com').post('/graphql', query) + .reply(200, response) + + expect(await getAlert('coffee', '4.0.1', '/wwwroot', mockGitHubClient, mockGitHubPullContext())).toEqual({ alertState: '', cvss: 0, ghsaId: '' }) +}) + +test('trimSlashes should only trim slashes from both ends', () => { + expect(trimSlashes('')).toEqual('') + expect(trimSlashes('///')).toEqual('') + expect(trimSlashes('/abc/')).toEqual('abc') + expect(trimSlashes('/a/b/c/')).toEqual('a/b/c') + 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 677a94aa..d0a08897 100644 --- a/src/dependabot/verified_commits.ts +++ b/src/dependabot/verified_commits.ts @@ -1,6 +1,8 @@ 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]' @@ -61,3 +63,54 @@ function warnOtherCommits (): void { 'any non-Dependabot changes.' ) } + +export async function getAlert (name: string, version: string, directory: string, client: InstanceType, context: Context): Promise { + const alerts: any = await client.graphql(` + { + repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") { + vulnerabilityAlerts(first: 100) { + nodes { + vulnerableManifestFilename + vulnerableManifestPath + vulnerableRequirements + state + securityVulnerability { + package { name } + } + securityAdvisory { + cvss { score } + ghsaId + } + } + } + } + }`) + + const nodes = alerts?.repository?.vulnerabilityAlerts?.nodes + const found = nodes.find(a => (version === '' || a.vulnerableRequirements === `= ${version}`) && + trimSlashes(a.vulnerableManifestPath) === `${trimSlashes(directory)}/${a.vulnerableManifestFilename}` && + a.securityVulnerability.package.name === name) + + return { + alertState: found?.state ?? '', + ghsaId: found?.securityAdvisory.ghsaId ?? '', + cvss: found?.securityAdvisory.cvss.score ?? 0.0 + } +} + +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 3a3a622a..b806d55e 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 } from './dependabot/verified_commits' +import { getMessage, getAlert, getCompatibility } from './dependabot/verified_commits' import { parse } from './dependabot/update_metadata' import { getBranchNames, parseNwo } from './dependabot/util' @@ -51,8 +51,9 @@ async function check (args: any): Promise<void> { if (commitMessage) { console.log('This appears to be a valid Dependabot Pull Request.') const branchNames = getBranchNames(newContext) + const lookupFn = (name, version, directory) => getAlert(name, version, directory, githubClient, actionContext) - const updatedDependencies = parse(commitMessage, branchNames.headName, branchNames.baseName) + 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 eb9c4c49..c863b229 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -23,6 +23,7 @@ test('it early exits with an error if github-token is not set', async () => { ) /* eslint-disable no-unused-expressions */ expect(dependabotCommits.getMessage).not.toHaveBeenCalled + expect(dependabotCommits.getAlert).not.toHaveBeenCalled /* eslint-enable no-unused-expressions */ }) @@ -38,6 +39,9 @@ test('it does nothing if the PR is not verified as from Dependabot', async () => expect(core.setFailed).toHaveBeenCalledWith( expect.stringContaining('PR is not from Dependabot, nothing to do.') ) + /* eslint-disable no-unused-expressions */ + expect(dependabotCommits.getAlert).not.toHaveBeenCalled + /* eslint-enable no-unused-expressions */ }) test('it does nothing if there is no metadata in the commit', async () => { @@ -52,6 +56,9 @@ test('it does nothing if there is no metadata in the commit', async () => { expect(core.setFailed).toHaveBeenCalledWith( expect.stringContaining('PR does not contain metadata, nothing to do.') ) + /* eslint-disable no-unused-expressions */ + expect(dependabotCommits.getAlert).not.toHaveBeenCalled + /* eslint-enable no-unused-expressions */ }) test('it sets the updated dependency as an output for subsequent actions', async () => { @@ -69,12 +76,19 @@ test('it sets the updated dependency as an output for subsequent actions', async '...\n' + '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' + const mockAlert = { alertState: 'FIXED', ghsaId: 'GSHA', cvss: 3.4 } - jest.spyOn(core, 'getInput').mockReturnValue('mock-token') + jest.spyOn(core, 'getInput').mockImplementation(jest.fn((name) => { return name === 'github-token' ? 'mock-token' : '' })) jest.spyOn(util, 'getBranchNames').mockReturnValue({ headName: 'dependabot|nuget|feature1', baseName: 'main' }) jest.spyOn(dependabotCommits, 'getMessage').mockImplementation(jest.fn( () => Promise.resolve(mockCommitMessage) )) + 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() @@ -92,7 +106,13 @@ test('it sets the updated dependency as an output for subsequent actions', async updateType: 'version-update:semver-minor', directory: '/', packageEcosystem: 'nuget', - targetBranch: 'main' + targetBranch: 'main', + prevVersion: '4.0.1', + newVersion: '4.2.2', + compatScore: 0, + alertState: '', + ghsaId: '', + cvss: 0 } ] ) @@ -103,10 +123,17 @@ test('it sets the updated dependency as an output for subsequent actions', async expect(core.setOutput).toBeCalledWith('directory', '/') expect(core.setOutput).toBeCalledWith('package-ecosystem', 'nuget') 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) }) test('if there are multiple dependencies, it summarizes them', async () => { const mockCommitMessage = + 'Bump coffee-rails from 4.0.1 to 4.2.2 in api/main\n' + 'Bumps [coffee-rails](https://github.com/rails/coffee-rails) from 4.0.1 to 4.2.2.\n' + '- [Release notes](https://github.com/rails/coffee-rails/releases)\n' + '- [Changelog](https://github.com/rails/coffee-rails/blob/master/CHANGELOG.md)\n' + @@ -123,12 +150,19 @@ test('if there are multiple dependencies, it summarizes them', async () => { '...\n' + '\n' + 'Signed-off-by: dependabot[bot] <support@github.com>' + const mockAlert = { alertState: '', ghsaId: '', cvss: 0 } jest.spyOn(core, 'getInput').mockReturnValue('mock-token') jest.spyOn(util, 'getBranchNames').mockReturnValue({ headName: 'dependabot/npm_and_yarn/api/main/feature1', baseName: 'trunk' }) jest.spyOn(dependabotCommits, 'getMessage').mockImplementation(jest.fn( () => Promise.resolve(mockCommitMessage) )) + 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() @@ -144,17 +178,29 @@ test('if there are multiple dependencies, it summarizes them', async () => { dependencyName: 'coffee-rails', dependencyType: 'direct:production', updateType: 'version-update:semver-minor', - directory: 'api/main', + directory: '/api/main', packageEcosystem: 'npm_and_yarn', - targetBranch: 'trunk' + targetBranch: 'trunk', + prevVersion: '4.0.1', + newVersion: '4.2.2', + compatScore: 34, + alertState: '', + ghsaId: '', + cvss: 0 }, { dependencyName: 'coffeescript', dependencyType: 'indirect', updateType: 'version-update:semver-major', - directory: 'api/main', + directory: '/api/main', packageEcosystem: 'npm_and_yarn', - targetBranch: 'trunk' + targetBranch: 'trunk', + prevVersion: '', + newVersion: '', + compatScore: 34, + alertState: '', + ghsaId: '', + cvss: 0 } ] ) @@ -162,9 +208,15 @@ test('if there are multiple dependencies, it summarizes them', async () => { expect(core.setOutput).toBeCalledWith('dependency-names', 'coffee-rails, coffeescript') expect(core.setOutput).toBeCalledWith('dependency-type', 'direct:production') expect(core.setOutput).toBeCalledWith('update-type', 'version-update:semver-major') - expect(core.setOutput).toBeCalledWith('directory', 'api/main') + expect(core.setOutput).toBeCalledWith('directory', '/api/main') expect(core.setOutput).toBeCalledWith('package-ecosystem', 'npm_and_yarn') 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) }) test('it sets the action to failed if there is an unexpected exception', async () => { @@ -179,6 +231,9 @@ test('it sets the action to failed if there is an unexpected exception', async ( expect(core.setFailed).toHaveBeenCalledWith( expect.stringContaining('Something bad happened!') ) + /* eslint-disable no-unused-expressions */ + expect(dependabotCommits.getAlert).not.toHaveBeenCalled + /* eslint-enable no-unused-expressions */ }) test('it sets the action to failed if there is a request error', async () => { @@ -202,4 +257,7 @@ test('it sets the action to failed if there is a request error', async () => { expect(core.setFailed).toHaveBeenCalledWith( expect.stringContaining('(500) Something bad happened!') ) + /* eslint-disable no-unused-expressions */ + expect(dependabotCommits.getAlert).not.toHaveBeenCalled + /* eslint-enable no-unused-expressions */ }) diff --git a/src/main.ts b/src/main.ts index 34211c46..3a534546 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,12 +24,17 @@ export async function run (): Promise<void> { // Validate the job const commitMessage = await verifiedCommits.getMessage(githubClient, github.context) const branchNames = util.getBranchNames(github.context) + let alertLookup: updateMetadata.alertLookup | undefined + 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 = updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName) + const updatedDependencies = await updateMetadata.parse(commitMessage, branchNames.headName, branchNames.baseName, alertLookup, scoreLookup) if (updatedDependencies.length > 0) { output.set(updatedDependencies)