Skip to content
Merged
14 changes: 14 additions & 0 deletions check-label/action.yml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions src/check-label/check-label-compare-match-semver.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
86 changes: 86 additions & 0 deletions src/check-label/check-label.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
80 changes: 80 additions & 0 deletions src/check-label/check-label.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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');
}
19 changes: 19 additions & 0 deletions src/check-label/index.ts
Original file line number Diff line number Diff line change
@@ -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);
});
55 changes: 55 additions & 0 deletions src/github-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,61 @@ export async function getPullRequestContext(): Promise<GithubConfigurationRespon
}
}

export async function getFileFromMain(filename: string): Promise<string | undefined> {
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<string[]> {
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
Expand Down
36 changes: 35 additions & 1 deletion src/nocks/github.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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);
Expand Down