mirror of
https://github.com/actions/labeler
synced 2026-09-04 11:05:09 +02:00
* Preserve externally managed pull request labels Replace whole-set label writes with batched selective additions and removals so sync-labels only manages configured labels. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 * Reconcile ambiguous label addition failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 * Handle paginated label reconciliation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 --------- Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182
553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
import {jest, describe, it, expect, beforeEach, beforeAll} from '@jest/globals';
|
|
import * as yaml from 'js-yaml';
|
|
import * as fs from 'fs';
|
|
import type {
|
|
MatchConfig,
|
|
BaseMatchConfig
|
|
} from '../src/api/get-label-configs.js';
|
|
|
|
// Define API mock functions at module level
|
|
const getPullRequestsMock = jest.fn<any>();
|
|
const getLabelConfigsMock = jest.fn<any>();
|
|
const addLabelsMock = jest.fn<any>();
|
|
const removeLabelsMock = jest.fn<any>();
|
|
const getChangedFilesMock = jest.fn<any>();
|
|
const getContentMock = jest.fn<any>();
|
|
|
|
jest.unstable_mockModule('@actions/core', () => ({
|
|
getInput: jest.fn(),
|
|
getMultilineInput: jest.fn(),
|
|
getBooleanInput: jest.fn(),
|
|
setOutput: jest.fn(),
|
|
setFailed: jest.fn(),
|
|
error: jest.fn(),
|
|
warning: jest.fn(),
|
|
info: jest.fn(),
|
|
debug: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('@actions/github', () => ({
|
|
context: {
|
|
payload: {
|
|
pull_request: {
|
|
number: 123,
|
|
head: {ref: 'head-branch'},
|
|
base: {ref: 'base-branch'}
|
|
}
|
|
},
|
|
repo: {owner: 'monalisa', repo: 'helloworld'}
|
|
},
|
|
getOctokit: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('../src/api/index.js', () => ({
|
|
getPullRequests: getPullRequestsMock,
|
|
getLabelConfigs: getLabelConfigsMock,
|
|
addLabels: addLabelsMock,
|
|
removeLabels: removeLabelsMock,
|
|
getChangedFiles: getChangedFilesMock,
|
|
getContent: getContentMock
|
|
}));
|
|
|
|
const core = await import('@actions/core');
|
|
const github = await import('@actions/github');
|
|
const api = await import('../src/api/index.js');
|
|
const {labeler, checkMatchConfigs} = await import('../src/labeler.js');
|
|
const {
|
|
toMatchConfig,
|
|
getLabelConfigMapFromObject,
|
|
getLabelConfigResultFromObject,
|
|
configUsesChangedFiles
|
|
} = await import('../src/api/get-label-configs.js');
|
|
|
|
beforeAll(() => {
|
|
(core.getInput as jest.Mock).mockImplementation(() => undefined);
|
|
});
|
|
|
|
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']}]},
|
|
{baseBranch: undefined, headBranch: ['regexp']},
|
|
{baseBranch: ['regexp'], headBranch: undefined}
|
|
]
|
|
},
|
|
{
|
|
all: [
|
|
{changedFiles: [{allGlobsToAllFiles: ['glob']}]},
|
|
{baseBranch: undefined, headBranch: ['regexp']},
|
|
{baseBranch: ['regexp'], headBranch: undefined}
|
|
]
|
|
}
|
|
]);
|
|
expected.set('label2', [
|
|
{
|
|
any: [
|
|
{changedFiles: [{anyGlobToAnyFile: ['glob']}]},
|
|
{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/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('toMatchConfig', () => {
|
|
describe('when all expected config options are present', () => {
|
|
const config = {
|
|
'changed-files': [{'any-glob-to-any-file': ['testing-files']}],
|
|
'head-branch': ['testing-head'],
|
|
'base-branch': ['testing-base']
|
|
};
|
|
const expected: BaseMatchConfig = {
|
|
changedFiles: [{anyGlobToAnyFile: ['testing-files']}],
|
|
headBranch: ['testing-head'],
|
|
baseBranch: ['testing-base']
|
|
};
|
|
|
|
it('returns a MatchConfig object with all options', () => {
|
|
const result = toMatchConfig(config);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('checkMatchConfigs', () => {
|
|
describe('when a single match config is provided', () => {
|
|
const matchConfig: MatchConfig[] = [
|
|
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]}
|
|
];
|
|
|
|
it('returns true when our pattern does match changed files', () => {
|
|
const changedFiles = ['foo.txt', 'bar.txt'];
|
|
const result = checkMatchConfigs(changedFiles, matchConfig, false);
|
|
|
|
expect(result).toBeTruthy();
|
|
});
|
|
|
|
it('returns false when our pattern does not match changed files', () => {
|
|
const changedFiles = ['foo.docx'];
|
|
const result = checkMatchConfigs(changedFiles, matchConfig, false);
|
|
|
|
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']}
|
|
]
|
|
}
|
|
];
|
|
const changedFiles = ['foo.txt', 'bar.txt'];
|
|
|
|
const result = checkMatchConfigs(changedFiles, matchConfig, false);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('when multiple MatchConfigs are supplied', () => {
|
|
const matchConfig: MatchConfig[] = [
|
|
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]},
|
|
{any: [{headBranch: ['some-branch']}]}
|
|
];
|
|
const changedFiles = ['foo.txt', 'bar.md'];
|
|
|
|
it('returns false when only one config matches', () => {
|
|
const result = checkMatchConfigs(changedFiles, matchConfig, false);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns true when only both config matches', () => {
|
|
const matchConfig: MatchConfig[] = [
|
|
{any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]},
|
|
{any: [{headBranch: ['head-branch']}]}
|
|
];
|
|
const result = checkMatchConfigs(changedFiles, matchConfig, false);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
getPullRequestsMock.mockReturnValue([
|
|
{
|
|
...mockPullRequest,
|
|
data: {labels: [{name: 'old-label'}]}
|
|
}
|
|
]);
|
|
|
|
getLabelConfigsMock.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 () => {
|
|
addLabelsMock.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'
|
|
};
|
|
addLabelsMock.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'
|
|
};
|
|
addLabelsMock.mockRejectedValue(error);
|
|
|
|
await labeler();
|
|
|
|
expect(core.warning).toHaveBeenCalledWith(
|
|
expect.stringContaining("requires 'issues: write'"),
|
|
expect.any(Object)
|
|
);
|
|
expect(core.setFailed).toHaveBeenCalledWith(error.message);
|
|
});
|
|
|
|
it('reports the configured labels when a bulk removal fails', async () => {
|
|
(core.getBooleanInput as jest.Mock).mockReturnValue(true);
|
|
getPullRequestsMock.mockReturnValue([
|
|
{
|
|
number: 123,
|
|
data: {
|
|
node_id: 'PR_node_id',
|
|
labels: [{name: 'stale-label', node_id: 'label_node_id'}]
|
|
},
|
|
changedFiles: ['file.txt']
|
|
}
|
|
]);
|
|
getLabelConfigsMock.mockResolvedValue({
|
|
labelConfigs: new Map([
|
|
[
|
|
'stale-label',
|
|
[{any: [{changedFiles: [{anyGlobToAnyFile: ['*.pdf']}]}]}]
|
|
]
|
|
]),
|
|
changedFilesLimit: undefined
|
|
});
|
|
removeLabelsMock.mockRejectedValue(new Error('GraphQL request failed'));
|
|
|
|
await expect(labeler()).rejects.toThrow(
|
|
"Failed to remove configured labels 'stale-label' from PR #123"
|
|
);
|
|
expect(addLabelsMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|