Files
labeler/__tests__/labeler.test.ts
T

483 lines
16 KiB
TypeScript
Raw Normal View History

2023-10-24 12:11:31 +02:00
import * as yaml from 'js-yaml';
import * as core from '@actions/core';
import * as api from '../src/api';
import {labeler} from '../src/labeler';
import * as github from '@actions/github';
2023-10-24 12:11:31 +02:00
import * as fs from 'fs';
import {checkMatchConfigs} from '../src/labeler';
2023-03-24 21:09:16 -04:00
import {
MatchConfig,
toMatchConfig,
getLabelConfigMapFromObject,
getLabelConfigResultFromObject,
BaseMatchConfig,
configUsesChangedFiles
2023-10-24 12:11:31 +02:00
} from '../src/api/get-label-configs';
2021-06-03 16:15:47 -04:00
jest.mock('@actions/core');
jest.mock('../src/api');
2021-06-03 16:15:47 -04:00
beforeAll(() => {
jest.spyOn(core, 'getInput').mockImplementation((name, options) => {
return jest.requireActual('@actions/core').getInput(name, options);
2021-06-03 16:15:47 -04:00
});
});
2023-03-24 21:09:16 -04:00
const loadYaml = (filepath: string) => {
const loadedFile = fs.readFileSync(filepath);
const content = Buffer.from(loadedFile).toString();
return yaml.load(content);
};
describe('getLabelConfigMapFromObject', () => {
const yamlObject = loadYaml('__tests__/fixtures/all_options.yml');
const expected = new Map<string, MatchConfig[]>();
expected.set('label1', [
{
any: [
{changedFiles: [{anyGlobToAnyFile: ['glob']}]},
2023-03-24 21:09:16 -04:00
{baseBranch: undefined, headBranch: ['regexp']},
{baseBranch: ['regexp'], headBranch: undefined}
]
},
{
all: [
{changedFiles: [{allGlobsToAllFiles: ['glob']}]},
2023-03-24 21:09:16 -04:00
{baseBranch: undefined, headBranch: ['regexp']},
{baseBranch: ['regexp'], headBranch: undefined}
]
}
]);
expected.set('label2', [
{
any: [
{changedFiles: [{anyGlobToAnyFile: ['glob']}]},
2023-03-24 21:09:16 -04:00
{baseBranch: undefined, headBranch: ['regexp']},
{baseBranch: ['regexp'], headBranch: undefined}
]
}
]);
it('returns a MatchConfig', () => {
const result = getLabelConfigMapFromObject(yamlObject);
expect(result).toEqual(expected);
});
it('ignores top-level options like changed-files-labels-limit and max-files-changed', () => {
const configWithLimit = {
'changed-files-labels-limit': 5,
'max-files-changed': 100,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigMapFromObject(configWithLimit);
expect(result.has('changed-files-labels-limit')).toBe(false);
expect(result.has('max-files-changed')).toBe(false);
expect(result.has('label1')).toBe(true);
});
});
describe('getLabelConfigResultFromObject', () => {
it('extracts changed-files-labels-limit as a number', () => {
const config = {
'changed-files-labels-limit': 5,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(5);
expect(result.labelConfigs.has('label1')).toBe(true);
});
it('parses changed-files-labels-limit from string', () => {
const config = {
'changed-files-labels-limit': '10',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(10);
});
it('trims whitespace when parsing string values', () => {
const config = {
'changed-files-labels-limit': ' 5 ',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(5);
});
it('returns undefined changedFilesLimit when not set', () => {
const config = {
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBeUndefined();
});
it('throws error for invalid changed-files-labels-limit value', () => {
const config = {
'changed-files-labels-limit': 'invalid',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/Invalid value for 'changed-files-labels-limit'/
);
});
it('throws error for negative changed-files-labels-limit value', () => {
const config = {
'changed-files-labels-limit': -1,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/must be a non-negative integer/
);
});
it('throws error for string with trailing characters', () => {
const config = {
'changed-files-labels-limit': '10abc',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/must be a non-negative integer/
);
});
it('throws error for decimal string', () => {
const config = {
'changed-files-labels-limit': '3.2',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/must be a non-negative integer/
);
});
it('throws error for float number', () => {
const config = {
'changed-files-labels-limit': 3.2,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/must be a non-negative integer/
);
});
it('accepts zero as a valid changed-files-labels-limit', () => {
const config = {
'changed-files-labels-limit': 0,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(0);
});
it('extracts max-files-changed as a number', () => {
const config = {
'max-files-changed': 100,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.maxFilesChanged).toBe(100);
expect(result.labelConfigs.has('label1')).toBe(true);
});
it('parses max-files-changed from string', () => {
const config = {
'max-files-changed': '50',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.maxFilesChanged).toBe(50);
});
it('returns undefined maxFilesChanged when not set', () => {
const config = {
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.maxFilesChanged).toBeUndefined();
});
it('throws error for invalid max-files-changed value', () => {
const config = {
'max-files-changed': 'invalid',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/Invalid value for 'max-files-changed'/
);
});
it('throws error for negative max-files-changed value', () => {
const config = {
'max-files-changed': -1,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/must be a non-negative integer/
);
});
it('accepts zero as a valid max-files-changed', () => {
const config = {
'max-files-changed': 0,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.maxFilesChanged).toBe(0);
});
it('supports both options together', () => {
const config = {
'changed-files-labels-limit': 5,
'max-files-changed': 100,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(5);
expect(result.maxFilesChanged).toBe(100);
});
it('throws a clear error when max-files-changed is used as a label', () => {
const config = {
'max-files-changed': [
{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}
]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/reserved top-level option and cannot be used as a label name/
);
});
it('throws a clear error when changed-files-labels-limit is used as a label', () => {
const config = {
'changed-files-labels-limit': [
{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}
]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/reserved top-level option and cannot be used as a label name/
);
});
2023-03-24 21:09:16 -04:00
});
2023-03-18 17:10:25 -04:00
describe('toMatchConfig', () => {
describe('when all expected config options are present', () => {
const config = {
'changed-files': [{'any-glob-to-any-file': ['testing-files']}],
2023-03-18 17:10:25 -04:00
'head-branch': ['testing-head'],
'base-branch': ['testing-base']
};
2023-03-24 21:09:16 -04:00
const expected: BaseMatchConfig = {
changedFiles: [{anyGlobToAnyFile: ['testing-files']}],
headBranch: ['testing-head'],
baseBranch: ['testing-base']
};
2023-03-18 17:10:25 -04:00
it('returns a MatchConfig object with all options', () => {
const result = toMatchConfig(config);
expect(result).toEqual(expected);
2023-03-18 17:10:25 -04:00
});
describe('and there are also unexpected options present', () => {
config['test-test'] = 'testing';
it('does not include the unexpected items in the returned MatchConfig object', () => {
const result = toMatchConfig(config);
expect(result).toEqual(expected);
2023-03-18 17:10:25 -04:00
});
});
});
});
2021-06-03 16:15:47 -04:00
describe('checkMatchConfigs', () => {
2023-03-25 16:07:56 -04:00
describe('when a single match config is provided', () => {
const matchConfig: MatchConfig[] = [
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]}
];
2023-03-18 17:10:25 -04:00
2023-03-25 16:07:56 -04:00
it('returns true when our pattern does match changed files', () => {
const changedFiles = ['foo.txt', 'bar.txt'];
2023-10-24 12:11:31 +02:00
const result = checkMatchConfigs(changedFiles, matchConfig, false);
2021-06-03 16:15:47 -04:00
2023-03-25 16:07:56 -04:00
expect(result).toBeTruthy();
});
it('returns false when our pattern does not match changed files', () => {
const changedFiles = ['foo.docx'];
2023-10-24 12:11:31 +02:00
const result = checkMatchConfigs(changedFiles, matchConfig, false);
2023-03-25 16:07:56 -04:00
expect(result).toBeFalsy();
});
it('returns true when either the branch or changed files patter matches', () => {
const matchConfig: MatchConfig[] = [
{
any: [
{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]},
{headBranch: ['some-branch']}
]
}
2023-03-25 16:07:56 -04:00
];
const changedFiles = ['foo.txt', 'bar.txt'];
2023-10-24 12:11:31 +02:00
const result = checkMatchConfigs(changedFiles, matchConfig, false);
2023-03-25 16:07:56 -04:00
expect(result).toBe(true);
});
2023-10-24 12:11:31 +02:00
it('returns false for a file starting with dot if `dot` option is false', () => {
const changedFiles = ['.foo.txt'];
const result = checkMatchConfigs(changedFiles, matchConfig, false);
expect(result).toBeFalsy();
});
it('returns true for a file starting with dot if `dot` option is true', () => {
const changedFiles = ['.foo.txt'];
const result = checkMatchConfigs(changedFiles, matchConfig, true);
expect(result).toBeTruthy();
});
2021-06-03 16:15:47 -04:00
});
2023-03-25 16:07:56 -04:00
describe('when multiple MatchConfigs are supplied', () => {
const matchConfig: MatchConfig[] = [
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]},
2023-03-25 16:07:56 -04:00
{any: [{headBranch: ['some-branch']}]}
];
const changedFiles = ['foo.txt', 'bar.md'];
2021-06-03 16:15:47 -04:00
2023-03-25 16:07:56 -04:00
it('returns false when only one config matches', () => {
2023-10-24 12:11:31 +02:00
const result = checkMatchConfigs(changedFiles, matchConfig, false);
2023-03-25 16:07:56 -04:00
expect(result).toBe(false);
});
it('returns true when only both config matches', () => {
const matchConfig: MatchConfig[] = [
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]},
2023-03-25 16:07:56 -04:00
{any: [{headBranch: ['head-branch']}]}
];
2023-10-24 12:11:31 +02:00
const result = checkMatchConfigs(changedFiles, matchConfig, false);
2023-03-25 16:07:56 -04:00
expect(result).toBe(true);
});
2021-06-03 16:15:47 -04:00
});
});
describe('configUsesChangedFiles', () => {
it('returns true when config has changed-files in any block', () => {
const matchConfig: MatchConfig[] = [
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]}
];
expect(configUsesChangedFiles(matchConfig)).toBe(true);
});
it('returns true when config has changed-files in all block', () => {
const matchConfig: MatchConfig[] = [
{all: [{changedFiles: [{allGlobsToAllFiles: ['*.txt']}]}]}
];
expect(configUsesChangedFiles(matchConfig)).toBe(true);
});
it('returns false when config only has branch patterns', () => {
const matchConfig: MatchConfig[] = [
{any: [{headBranch: ['^test/']}]},
{any: [{baseBranch: ['main']}]}
];
expect(configUsesChangedFiles(matchConfig)).toBe(false);
});
it('returns false when config has empty changed-files array', () => {
const matchConfig: MatchConfig[] = [{any: [{changedFiles: []}]}];
expect(configUsesChangedFiles(matchConfig)).toBe(false);
});
it('returns false when config has changed-files with empty objects', () => {
const matchConfig: MatchConfig[] = [{any: [{changedFiles: [{}]}]}];
expect(configUsesChangedFiles(matchConfig)).toBe(false);
});
it('returns true when config has mixed branch and changed-files patterns', () => {
const matchConfig: MatchConfig[] = [
{
any: [
{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]},
{headBranch: ['^feature/']}
]
}
];
expect(configUsesChangedFiles(matchConfig)).toBe(true);
});
});
describe('labeler error handling', () => {
const mockClient = {} as any;
const mockPullRequest = {
number: 123,
data: {labels: []},
changedFiles: []
};
beforeEach(() => {
jest.resetAllMocks();
(github.getOctokit as jest.Mock).mockReturnValue(mockClient);
(api.getPullRequests as jest.Mock).mockReturnValue([
{
...mockPullRequest,
data: {labels: [{name: 'old-label'}]}
}
]);
(api.getLabelConfigs as jest.Mock).mockResolvedValue({
labelConfigs: new Map([['new-label', ['dummy-config']]]),
changedFilesLimit: undefined
});
// Force match so "new-label" is always added
jest.spyOn({checkMatchConfigs}, 'checkMatchConfigs').mockReturnValue(true);
});
it('throws a custom error for HttpError 403 with "unauthorized" message', async () => {
(api.setLabels as jest.Mock).mockRejectedValue({
name: 'HttpError',
status: 403,
message: 'Request failed with status code 403: Unauthorized'
});
await expect(labeler()).rejects.toThrow(
/does not have permission to create labels/
);
});
it('rethrows unexpected HttpError', async () => {
const unexpectedError = {
name: 'HttpError',
status: 404,
message: 'Not Found'
};
(api.setLabels as jest.Mock).mockRejectedValue(unexpectedError);
// NOTE: In the current implementation, labeler rethrows the raw error object (not an Error instance).
// `rejects.toThrow` only works with real Error objects, so here we must use `rejects.toEqual`.
// If labeler is updated to always wrap errors in `Error`, this test can be changed to use `rejects.toThrow`.
await expect(labeler()).rejects.toEqual(unexpectedError);
});
it('handles "Resource not accessible by integration" gracefully', async () => {
const error = {
name: 'HttpError',
message: 'Resource not accessible by integration'
};
(api.setLabels as jest.Mock).mockRejectedValue(error);
await labeler();
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining("requires 'issues: write'"),
expect.any(Object)
);
expect(core.setFailed).toHaveBeenCalledWith(error.message);
});
});