Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
🧹 Add basic unit tests (#148)
This is not intended to be a comprehensive test suite; I'm just trying to get us started by adding tests for some of the most important functionality.

This necessitated some minor refactoring. Previously, `main.ts` just directly invoked the main entrypoint function `run()`, which made it impossible to unit test the module without causing the side-effects of `run`. 

As a workaround I created  a new module `labeler.ts`, which just exports the functions we want to test without executing the main entrypoint, and simplified `main.ts` so that it only imports the entrypoint and executes it.

It's basically just a re-org. The diff makes it look way more complicated than it is.
  • Loading branch information
Patrick Ellis authored and GitHub committed Jun 3, 2021
1 parent ffa3fbe commit 9019323
Show file tree
Hide file tree
Showing 4 changed files with 289 additions and 262 deletions.
29 changes: 29 additions & 0 deletions __tests__/labeler.test.ts
@@ -0,0 +1,29 @@
import { checkGlobs } from '../src/labeler'

import * as core from "@actions/core";

jest.mock("@actions/core");

beforeAll(() => {
jest.spyOn(core, "getInput").mockImplementation((name, options) => {
return jest.requireActual("@actions/core").getInput(name, options);
});
});

const matchConfig = [{ any: ["*.txt"] }];

describe('checkGlobs', () => {
it('returns true when our pattern does match changed files', () => {
const changedFiles = ["foo.txt", "bar.txt"];
const result = checkGlobs(changedFiles, matchConfig);

expect(result).toBeTruthy();
});

it('returns false when our pattern does not match changed files', () => {
const changedFiles = ["foo.docx"];
const result = checkGlobs(changedFiles, matchConfig);

expect(result).toBeFalsy();
});
});
3 changes: 0 additions & 3 deletions __tests__/main.test.ts

This file was deleted.

259 changes: 259 additions & 0 deletions src/labeler.ts
@@ -0,0 +1,259 @@
import * as core from "@actions/core";
import * as github from "@actions/github";
import * as yaml from "js-yaml";
import { Minimatch, IMinimatch } from "minimatch";

interface MatchConfig {
all?: string[];
any?: string[];
}

type StringOrMatchConfig = string | MatchConfig;

export async function run() {
try {
const token = core.getInput("repo-token", { required: true });
const configPath = core.getInput("configuration-path", { required: true });
const syncLabels = !!core.getInput("sync-labels", { required: false });

const prNumber = getPrNumber();
if (!prNumber) {
console.log("Could not get pull request number from context, exiting");
return;
}

const client = new github.GitHub(token);

const { data: pullRequest } = await client.pulls.get({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: prNumber
});

core.debug(`fetching changed files for pr #${prNumber}`);
const changedFiles: string[] = await getChangedFiles(client, prNumber);
const labelGlobs: Map<string, StringOrMatchConfig[]> = await getLabelGlobs(
client,
configPath
);

const labels: string[] = [];
const labelsToRemove: string[] = [];
for (const [label, globs] of labelGlobs.entries()) {
core.debug(`processing ${label}`);
if (checkGlobs(changedFiles, globs)) {
labels.push(label);
} else if (pullRequest.labels.find(l => l.name === label)) {
labelsToRemove.push(label);
}
}

if (labels.length > 0) {
await addLabels(client, prNumber, labels);
}

if (syncLabels && labelsToRemove.length) {
await removeLabels(client, prNumber, labelsToRemove);
}
} catch (error) {
core.error(error);
core.setFailed(error.message);
}
}

function getPrNumber(): number | undefined {
const pullRequest = github.context.payload.pull_request;
if (!pullRequest) {
return undefined;
}

return pullRequest.number;
}

async function getChangedFiles(
client: github.GitHub,
prNumber: number
): Promise<string[]> {
const listFilesOptions = client.pulls.listFiles.endpoint.merge({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: prNumber
});

const listFilesResponse = await client.paginate(listFilesOptions);
const changedFiles = listFilesResponse.map(f => f.filename);

core.debug("found changed files:");
for (const file of changedFiles) {
core.debug(" " + file);
}

return changedFiles;
}

async function getLabelGlobs(
client: github.GitHub,
configurationPath: string
): Promise<Map<string, StringOrMatchConfig[]>> {
const configurationContent: string = await fetchContent(
client,
configurationPath
);

// loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`:
const configObject: any = yaml.safeLoad(configurationContent);

// transform `any` => `Map<string,StringOrMatchConfig[]>` or throw if yaml is malformed:
return getLabelGlobMapFromObject(configObject);
}

async function fetchContent(
client: github.GitHub,
repoPath: string
): Promise<string> {
const response: any = await client.repos.getContents({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
path: repoPath,
ref: github.context.sha
});

return Buffer.from(response.data.content, response.data.encoding).toString();
}

function getLabelGlobMapFromObject(
configObject: any
): Map<string, StringOrMatchConfig[]> {
const labelGlobs: Map<string, StringOrMatchConfig[]> = new Map();
for (const label in configObject) {
if (typeof configObject[label] === "string") {
labelGlobs.set(label, [configObject[label]]);
} else if (configObject[label] instanceof Array) {
labelGlobs.set(label, configObject[label]);
} else {
throw Error(
`found unexpected type for label ${label} (should be string or array of globs)`
);
}
}

return labelGlobs;
}

function toMatchConfig(config: StringOrMatchConfig): MatchConfig {
if (typeof config === "string") {
return {
any: [config]
};
}

return config;
}

function printPattern(matcher: IMinimatch): string {
return (matcher.negate ? "!" : "") + matcher.pattern;
}

export function checkGlobs(
changedFiles: string[],
globs: StringOrMatchConfig[]
): boolean {
for (const glob of globs) {
core.debug(` checking pattern ${JSON.stringify(glob)}`);
const matchConfig = toMatchConfig(glob);
if (checkMatch(changedFiles, matchConfig)) {
return true;
}
}
return false;
}

function isMatch(changedFile: string, matchers: IMinimatch[]): boolean {
core.debug(` matching patterns against file ${changedFile}`);
for (const matcher of matchers) {
core.debug(` - ${printPattern(matcher)}`);
if (!matcher.match(changedFile)) {
core.debug(` ${printPattern(matcher)} did not match`);
return false;
}
}

core.debug(` all patterns matched`);
return true;
}

// equivalent to "Array.some()" but expanded for debugging and clarity
function checkAny(changedFiles: string[], globs: string[]): boolean {
const matchers = globs.map(g => new Minimatch(g));
core.debug(` checking "any" patterns`);
for (const changedFile of changedFiles) {
if (isMatch(changedFile, matchers)) {
core.debug(` "any" patterns matched against ${changedFile}`);
return true;
}
}

core.debug(` "any" patterns did not match any files`);
return false;
}

// equivalent to "Array.every()" but expanded for debugging and clarity
function checkAll(changedFiles: string[], globs: string[]): boolean {
const matchers = globs.map(g => new Minimatch(g));
core.debug(` checking "all" patterns`);
for (const changedFile of changedFiles) {
if (!isMatch(changedFile, matchers)) {
core.debug(` "all" patterns did not match against ${changedFile}`);
return false;
}
}

core.debug(` "all" patterns matched all files`);
return true;
}

function checkMatch(changedFiles: string[], matchConfig: MatchConfig): boolean {
if (matchConfig.all !== undefined) {
if (!checkAll(changedFiles, matchConfig.all)) {
return false;
}
}

if (matchConfig.any !== undefined) {
if (!checkAny(changedFiles, matchConfig.any)) {
return false;
}
}

return true;
}

async function addLabels(
client: github.GitHub,
prNumber: number,
labels: string[]
) {
await client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: prNumber,
labels: labels
});
}

async function removeLabels(
client: github.GitHub,
prNumber: number,
labels: string[]
) {
await Promise.all(
labels.map(label =>
client.issues.removeLabel({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: prNumber,
name: label
})
)
);
}

0 comments on commit 9019323

Please sign in to comment.