diff --git a/check-label/action.yml b/check-label/action.yml new file mode 100644 index 0000000..513eb9c --- /dev/null +++ b/check-label/action.yml @@ -0,0 +1,14 @@ +name: Check Label +description: Check package.json version bump matches label +inputs: + github-token: + description: Github token + required: true + default: ${{ github.token }} + command: + description: command to run + required: true +runs: + using: node16 + pre: '../setup.mjs' + main: ../build/check-label/index.js diff --git a/src/check-label/check-label-compare-match-semver.spec.ts b/src/check-label/check-label-compare-match-semver.spec.ts new file mode 100644 index 0000000..b22c7c9 --- /dev/null +++ b/src/check-label/check-label-compare-match-semver.spec.ts @@ -0,0 +1,58 @@ +// check-label/check-label-compare-match-semver.spec.ts + +import { strict as assert } from 'node:assert'; + +import { validateVersion } from './check-label'; + +const assertError = 'Version is incorrect based on Pull Request label'; + +describe('compare and match semver', () => { + it('Test basic patch', async () => { + assert.equal(validateVersion('1.0.1', '1.0.0', 'patch'), true); + + assert.throws(() => validateVersion('1.0.0', '1.0.0', 'patch'), { + message: assertError, + }); + }); + + it('Test minor', async () => { + assert.equal(validateVersion('1.1.0', '1.0.2', 'minor'), true); + + assert.throws(() => validateVersion('1.1.1', '1.0.1', 'minor'), { + message: assertError, + }); + assert.throws(() => validateVersion('1.1.2', '1.0.1', 'minor'), { + message: assertError, + }); + }); + + it('Test major', async () => { + assert.equal(validateVersion('2.0.0', '1.0.2', 'major'), true); + + assert.throws(() => validateVersion('2.20.0', '1.20.1', 'major'), { + message: assertError, + }); + + assert.throws(() => validateVersion('2.0.1', '1.20.1', 'major'), { + message: assertError, + }); + }); + + it('Test major fails with large version gap', async () => { + assert.throws(() => validateVersion('8.0.0', '1.0.0', 'major'), { + message: assertError, + }); + }); + + it('Test throws when main is ahead', async () => { + assert.throws(() => validateVersion('8.0.0', '9.0.0', 'major'), { + message: 'main version is ahead of branch version', + }); + }); + + it('Test invalid label', async () => { + assert.throws(() => validateVersion('1.0.0', '1.0.0', 'invalid'), { + message: 'Invalid label', + }); + }); +}); diff --git a/src/check-label/check-label.spec.ts b/src/check-label/check-label.spec.ts new file mode 100644 index 0000000..0b0bfd0 --- /dev/null +++ b/src/check-label/check-label.spec.ts @@ -0,0 +1,86 @@ +// check-label/check-label.spec.ts + +import { strict as assert } from 'node:assert'; +import process from 'node:process'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { v4 as uuid } from 'uuid'; + +import gitHubNock from '../nocks/github.test'; +import checkLabel from './check-label'; + +async function createContext(prNumber: number) { + process.env['GITHUB_REPOSITORY'] = 'checkdigit/testlabel'; + const filePath = path.join(tmpdir(), 'actioncontexttestlabel', uuid()); + await writeFile( + filePath, + JSON.stringify({ + // eslint-disable-next-line camelcase + pull_request: { + number: prNumber, + }, + }) + ); + process.env['GITHUB_EVENT_PATH'] = filePath; +} + +function semverSubtract(version: string, versionLabel: 'patch' | 'major' | 'minor'): string { + const versionParts = version.split('.'); + if (versionLabel === 'major' && Number(versionParts[1]) !== 0) { + versionParts[0] = (Number(versionParts[0]) - 1).toString(); + } + + if (versionLabel === 'minor' && Number(versionParts[1]) !== 0) { + versionParts[1] = (Number(versionParts[1]) - 1).toString(); + } + + if (versionLabel === 'patch' && Number(versionParts[2]) !== 0) { + versionParts[2] = (Number(versionParts[2]) - 1).toString(); + } + + return versionParts.join('.'); +} + +describe('check label', () => { + beforeAll(async () => mkdir(path.join(tmpdir(), 'actioncontexttestlabel'))); + afterAll(async () => rm(path.join(tmpdir(), 'actioncontexttestlabel'), { recursive: true })); + + it('Test with no labels throws correctly', async () => { + // assert that the call to checkLabel rejects a promise + await assert.rejects(checkLabel()); + }); + + it('label matches - patch', async () => { + process.env['GITHUB_TOKEN'] = 'token 0000000000000000000000000000000000000001'; + + const packageJsonRaw = await readFile(path.join(process.cwd(), 'package.json'), 'utf8'); + const packageJson = JSON.parse(packageJsonRaw); + + const targetVersion = semverSubtract(packageJson.version, 'patch'); + assert(targetVersion !== null); + gitHubNock({ labelPackageVersionMain: targetVersion }); + + await createContext(10); + // assert that the call to checkLabel rejects a promise + await assert.doesNotReject(checkLabel()); + }); + + it('label does not match - should be major but is patch', async () => { + process.env['GITHUB_TOKEN'] = 'token 0000000000000000000000000000000000000001'; + + const packageJsonRaw = await readFile(path.join(process.cwd(), 'package.json'), 'utf8'); + const packageJson = JSON.parse(packageJsonRaw); + + const targetVersion = semverSubtract(packageJson.version, 'patch'); + assert(targetVersion !== null); + gitHubNock({ labelPackageVersionMain: targetVersion }); + + await createContext(11); + // assert that the call to checkLabel rejects a promise + + await assert.rejects(checkLabel(), { + message: 'Version is incorrect based on Pull Request label', + }); + }); +}); diff --git a/src/check-label/check-label.ts b/src/check-label/check-label.ts new file mode 100644 index 0000000..13f4fc2 --- /dev/null +++ b/src/check-label/check-label.ts @@ -0,0 +1,80 @@ +// check-label/check-label.ts + +import process from 'node:process'; +import path from 'node:path'; +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { debug } from 'debug'; +import semver from 'semver'; + +import { getFileFromMain, getLabelsOnPR } from '../github-api'; + +const log = debug('check-label'); + +interface PackageJSON { + name: string; + version: string; + files: string[]; +} + +async function getLocalPackageJsonVersion(fileName: string): Promise { + const packageJSONPath = path.join(process.cwd(), fileName); + const readPackageJson = await readFile(packageJSONPath, 'utf8'); + const packageJson = JSON.parse(readPackageJson) as PackageJSON; + + return packageJson.version; +} + +export function validateVersion( + branchPackageJsonVersion: string, + mainPackageJsonVersion: string, + prLabel: string +): true { + if (semver.gt(mainPackageJsonVersion, branchPackageJsonVersion)) { + log(`Main branch version: ${mainPackageJsonVersion} vs branch version: ${branchPackageJsonVersion}`); + throw new Error('main version is ahead of branch version'); + } + + const mainVersionSplit = mainPackageJsonVersion.split('.'); + + if (prLabel === 'patch') { + mainVersionSplit[2] = (Number(mainVersionSplit[2]) + 1).toString(); + } else if (prLabel === 'minor') { + mainVersionSplit[1] = (Number(mainVersionSplit[1]) + 1).toString(); + mainVersionSplit[2] = '0'; + } else if (prLabel === 'major') { + mainVersionSplit[0] = (Number(mainVersionSplit[0]) + 1).toString(); + mainVersionSplit[1] = '0'; + mainVersionSplit[2] = '0'; + } else { + throw new Error('Invalid label'); + } + + const expectedVersion = mainVersionSplit.join('.'); + assert.equal(expectedVersion, branchPackageJsonVersion, 'Version is incorrect based on Pull Request label'); + return true; +} + +export default async function (): Promise { + log('Action start'); + + const labelsPullRequest = await getLabelsOnPR(); + if (labelsPullRequest.length > 1) { + throw new Error('PR has more than one label'); + } + const label = labelsPullRequest[0]?.toLowerCase(); + assert(label, 'Unable to get label from PR'); + + const branchPackageJsonVersion = await getLocalPackageJsonVersion('package.json'); + const mainPackageJsonVersionRaw = await getFileFromMain('package.json'); + + if (!mainPackageJsonVersionRaw) { + throw new Error('Unable to get package.json from main branch'); + } + const mainPackageJsonVersion = JSON.parse(mainPackageJsonVersionRaw) as PackageJSON; + + validateVersion(branchPackageJsonVersion, mainPackageJsonVersion.version, label); + + const branchLockFile = await getLocalPackageJsonVersion('package-lock.json'); + assert.equal(branchLockFile, branchPackageJsonVersion, 'package.json and package-lock.json versions do not match'); +} diff --git a/src/check-label/index.ts b/src/check-label/index.ts new file mode 100644 index 0000000..f8caa9e --- /dev/null +++ b/src/check-label/index.ts @@ -0,0 +1,19 @@ +// check-label/index.ts + +import process from 'node:process'; + +import checkLabel from './check-label'; + +checkLabel() + .then(() => { + process.stdin.destroy(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + }) + // eslint-disable-next-line unicorn/prefer-top-level-await + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Action Error - exit 1 - error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }); diff --git a/src/github-api/index.ts b/src/github-api/index.ts index 1d750e1..59c33d3 100644 --- a/src/github-api/index.ts +++ b/src/github-api/index.ts @@ -52,6 +52,61 @@ export async function getPullRequestContext(): Promise { + if (!process.env['GITHUB_TOKEN']) { + log('getFileFromMain - GITHUB_TOKEN is not set - check action configuration'); + throw new Error(THROW_ACTION_ERROR_MESSAGE); + } + const octokat = new Octokit({ auth: process.env['GITHUB_TOKEN'] }); + + const githubContext = await getPullRequestContext(); + if (!githubContext) { + log('getFileFromMain Error - unable to get github context'); + throw new Error(THROW_UNABLE_TO_GET_CONTEXT); + } + + // get the labels attached to the PR + const { data } = (await octokat.rest.repos.getContent({ + owner: githubContext.owner, + repo: githubContext.repo, + path: filename, + ref: 'main', + mediaType: { + format: 'raw', + }, + })) as unknown as { data: string }; + + log('getFileFromMain - data', data); + + if (!data) { + return; + } + return data; +} + +export async function getLabelsOnPR(): Promise { + if (!process.env['GITHUB_TOKEN']) { + log('getLabelsOnPR - GITHUB_TOKEN is not set - check action configuration'); + throw new Error(THROW_ACTION_ERROR_MESSAGE); + } + const octokat = new Octokit({ auth: process.env['GITHUB_TOKEN'] }); + + const githubContext = await getPullRequestContext(); + if (!githubContext) { + log('getLabelsOnPR Error - unable to get github context'); + throw new Error(THROW_UNABLE_TO_GET_CONTEXT); + } + + const pullReqeust = await octokat.rest.pulls.get({ + owner: githubContext.owner, + repo: githubContext.repo, + // eslint-disable-next-line camelcase + pull_number: githubContext.number, + }); + + return pullReqeust.data.labels.map((label) => label.name); +} + export async function publishCommentAndRemovePrevious( message: string, prefixOfPreviousMessageToRemove?: string diff --git a/src/nocks/github.test.ts b/src/nocks/github.test.ts index c865743..62dbffd 100644 --- a/src/nocks/github.test.ts +++ b/src/nocks/github.test.ts @@ -1,7 +1,10 @@ // nocks/github.test.ts import nock from 'nock'; -export default function (): void { +export interface GithubNock { + labelPackageVersionMain?: string; +} +export default function (options?: GithubNock): void { nock('https://api.github.com/').persist().get('/repos/checkdigit/nocomments/issues/10/comments').reply(200); nock('https://api.github.com/').persist().post('/repos/checkdigit/nocomments/issues/10/comments').reply(200); @@ -102,6 +105,37 @@ export default function (): void { }, })); + // return label + nock('https://api.github.com/') + .persist() + .get('/repos/checkdigit/testlabel/pulls/10') + .reply(200, () => ({ + labels: [{ name: 'patch' }], + })); + + nock('https://api.github.com/') + .persist() + .get('/repos/checkdigit/testlabel/pulls/11') + .reply(200, () => ({ + labels: [{ name: 'major' }], + })); + + // return a raw package json file + nock('https://api.github.com/') + .persist() + .get('/repos/checkdigit/testlabel/contents/package.json?ref=main') + .reply(200, () => { + if (options?.labelPackageVersionMain) { + return `{"version": "${options.labelPackageVersionMain}"}`; + } + return '{"version": "1.0.0"}'; + }); + + nock('https://api.github.com/') + .persist() + .get('/repos/checkdigit/testlabel/contents/package-lock.json?ref=main') + .reply(200, () => '{"version": "1.0.0"}'); + // allow delete operations to the two comments that should be deleted nock('https://api.github.com/').persist().delete('/repos/checkdigit/comments/issues/comments/1').reply(200); nock('https://api.github.com/').persist().delete('/repos/checkdigit/comments/issues/comments/3').reply(200);