Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ The base match object is defined as:
- all-globs-to-all-files: ['list', 'of', 'globs']
- base-branch: ['list', 'of', 'regexps']
- head-branch: ['list', 'of', 'regexps']
- description: 'Description of label'
- color: 'Color of label'
```

There are two top-level keys, `any` and `all`, which both accept the same configuration options:
Expand Down Expand Up @@ -79,6 +81,8 @@ The fields are defined as follows:
- `any-glob-to-all-files`: ANY glob must match against ALL changed files
- `all-globs-to-any-file`: ALL globs must match against ANY changed file
- `all-globs-to-all-files`: ALL globs must match against ALL changed files
- `description`: The description of the label if creating it
- `color`: The color of the label if creating it (6-character hex, with or without `#`)

If a base option is provided without a top-level key, then it will default to `any`. More specifically, the following two configurations are equivalent:
```yml
Expand Down Expand Up @@ -151,6 +155,13 @@ feature:
# Add 'release' label to any PR that is opened against the `main` branch
release:
- base-branch: 'main'

# Define label properties
created-label:
- changed-files:
- any-glob-to-any-file: '*'
- description: "This label was created if it didn't already exist"
- color: "abcdef"
```

### Create Workflow
Expand Down Expand Up @@ -319,4 +330,4 @@ Once you confirm that the updated configuration files function as intended, you

## Contributions

Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
15 changes: 13 additions & 2 deletions __mocks__/@actions/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ export const context = {
const mockApi = {
rest: {
issues: {
setLabels: jest.fn()
setLabels: jest.fn(),
updateLabel: jest.fn(),
createLabel: jest.fn(),
listLabelsForRepo: {
endpoint: {
merge: jest.fn().mockReturnValue({
__labelerMock: 'listLabelsForRepo'
})
}
}
},
pulls: {
get: jest.fn().mockResolvedValue({
Expand All @@ -29,7 +38,9 @@ const mockApi = {
}),
listFiles: {
endpoint: {
merge: jest.fn().mockReturnValue({})
merge: jest.fn().mockReturnValue({
__labelerMock: 'listFiles'
})
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions __tests__/fixtures/all_options.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ label1:
- all-globs-to-all-files: ['glob']
- head-branch: ['regexp']
- base-branch: ['regexp']
- description: 'Label1 description'
- color: 'ff00ff'

label2:
- changed-files:
- any-glob-to-any-file: ['glob']
- head-branch: ['regexp']
- base-branch: ['regexp']
- description: 'Label2 description'
- color: 'ffff00'
20 changes: 20 additions & 0 deletions __tests__/fixtures/label_meta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
label1:
- any:
- changed-files:
- any-glob-to-any-file: ['tests/**/*']

label2:
- changed-files:
- any-glob-to-any-file: ['tests/**/*']
- description: 'Label2 description'
- color: 'ff00ff'

label3:
- changed-files:
- any-glob-to-any-file: ['tests/**/*']
- description: 'Label3 description'

label4:
- changed-files:
- any-glob-to-any-file: ['tests/**/*']
- color: '#000000'
110 changes: 110 additions & 0 deletions __tests__/labeler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {checkMatchConfigs} from '../src/labeler';
import {
MatchConfig,
toMatchConfig,
toLabelConfig,
getLabelConfigMapFromObject,
BaseMatchConfig
} from '../src/api/get-label-configs';
import {updateLabels} from '../src/api/set-labels';

jest.mock('@actions/core');
jest.mock('../src/api');
Expand Down Expand Up @@ -44,6 +46,12 @@ describe('getLabelConfigMapFromObject', () => {
{baseBranch: undefined, headBranch: ['regexp']},
{baseBranch: ['regexp'], headBranch: undefined}
]
},
{
meta: {
description: 'Label1 description',
color: 'ff00ff'
}
}
]);
expected.set('label2', [
Expand All @@ -53,6 +61,12 @@ describe('getLabelConfigMapFromObject', () => {
{baseBranch: undefined, headBranch: ['regexp']},
{baseBranch: ['regexp'], headBranch: undefined}
]
},
{
meta: {
description: 'Label2 description',
color: 'ffff00'
}
}
]);

Expand Down Expand Up @@ -91,6 +105,24 @@ describe('toMatchConfig', () => {
});
});

describe('toLabelConfig', () => {
it('normalizes color values and accepts # prefixes', () => {
const warningSpy = jest.spyOn(core, 'warning').mockImplementation();
const result = toLabelConfig({color: '#ff00ff'});
expect(result).toEqual({color: 'ff00ff'});
expect(warningSpy).not.toHaveBeenCalled();
warningSpy.mockRestore();
});

it('warns and drops invalid color values', () => {
const warningSpy = jest.spyOn(core, 'warning').mockImplementation();
const result = toLabelConfig({color: '#fff'});
expect(result).toEqual({});
expect(warningSpy).toHaveBeenCalledTimes(1);
warningSpy.mockRestore();
});
});

describe('checkMatchConfigs', () => {
describe('when a single match config is provided', () => {
const matchConfig: MatchConfig[] = [
Expand Down Expand Up @@ -233,3 +265,81 @@ describe('labeler error handling', () => {
expect(core.setFailed).toHaveBeenCalledWith(error.message);
});
});

describe('updateLabels', () => {
const gh = github.getOctokit('_');
const updateLabelMock = jest.spyOn(gh.rest.issues, 'updateLabel');
const createLabelMock = jest.spyOn(gh.rest.issues, 'createLabel');
const paginateMock = jest.spyOn(gh, 'paginate');

const buildLabelConfigs = (
meta: MatchConfig['meta']
): Map<string, MatchConfig[]> => new Map([['label1', [{meta}]]]);

beforeEach(() => {
jest.clearAllMocks();
});

it('updates existing labels when metadata differs', async () => {
paginateMock.mockResolvedValue([
{name: 'label1', color: '000000', description: 'old'}
]);

const labelConfigs = buildLabelConfigs({
color: 'ff00ff',
description: 'new'
});
const repoLabelCache = new Map();

await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);

expect(updateLabelMock).toHaveBeenCalledTimes(1);
expect(updateLabelMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
name: 'label1',
color: 'ff00ff',
description: 'new'
});
expect(createLabelMock).toHaveBeenCalledTimes(0);
});

it('does not update labels when metadata matches', async () => {
paginateMock.mockResolvedValue([
{name: 'label1', color: 'ff00ff', description: 'same'}
]);

const labelConfigs = buildLabelConfigs({
color: 'ff00ff',
description: 'same'
});
const repoLabelCache = new Map();

await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);

expect(updateLabelMock).toHaveBeenCalledTimes(0);
expect(createLabelMock).toHaveBeenCalledTimes(0);
});

it('creates labels when missing from the repository', async () => {
paginateMock.mockResolvedValue([]);

const labelConfigs = buildLabelConfigs({
color: 'ff00ff',
description: 'new'
});
const repoLabelCache = new Map();

await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);

expect(createLabelMock).toHaveBeenCalledTimes(1);
expect(createLabelMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
name: 'label1',
color: 'ff00ff',
description: 'new'
});
expect(updateLabelMock).toHaveBeenCalledTimes(0);
});
});
100 changes: 98 additions & 2 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ jest.mock('@actions/github');

const gh = github.getOctokit('_');
const setLabelsMock = jest.spyOn(gh.rest.issues, 'setLabels');
const updateLabelsMock = jest.spyOn(gh.rest.issues, 'updateLabel');
const createLabelsMock = jest.spyOn(gh.rest.issues, 'createLabel');
const reposMock = jest.spyOn(gh.rest.repos, 'getContent');
const paginateMock = jest.spyOn(gh, 'paginate');
const getPullMock = jest.spyOn(gh.rest.pulls, 'get');
Expand Down Expand Up @@ -37,7 +39,8 @@ const yamlFixtures = {
'branches.yml': fs.readFileSync('__tests__/fixtures/branches.yml'),
'only_pdfs.yml': fs.readFileSync('__tests__/fixtures/only_pdfs.yml'),
'not_supported.yml': fs.readFileSync('__tests__/fixtures/not_supported.yml'),
'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml')
'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml'),
'label_meta.yml': fs.readFileSync('__tests__/fixtures/label_meta.yml')
};

const configureInput = (
Expand Down Expand Up @@ -471,6 +474,78 @@ describe('run', () => {
expect(reposMock).toHaveBeenCalled();
});

it('creates missing labels with metadata', async () => {
configureInput({
'repo-token': 'foo',
'configuration-path': 'bar'
});

usingLabelerConfigYaml('label_meta.yml');
mockGitHubResponseChangedFiles('tests/test.txt');
mockGitHubResponseRepoLabels([]);
getPullMock.mockResolvedValue(<any>{
data: {
labels: []
}
});

await run();

expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['label1', 'label2', 'label3', 'label4']
});

expect(createLabelsMock).toHaveBeenCalledTimes(3);
expect(createLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
name: 'label2',
color: 'ff00ff',
description: 'Label2 description'
});
expect(createLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
name: 'label3',
description: 'Label3 description'
});
expect(createLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
name: 'label4',
color: '000000'
});
expect(updateLabelsMock).toHaveBeenCalledTimes(0);
expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued
});

it('does not create labels or issue warnings if all labels exist', async () => {
configureInput({
'repo-token': 'foo',
'configuration-path': 'bar'
});

usingLabelerConfigYaml('only_pdfs.yml');
mockGitHubResponseChangedFiles('foo.pdf');
getPullMock.mockResolvedValue(<any>{
data: {
labels: [{name: 'touched-a-pdf-file'}]
}
});

usingLabelerConfigYaml('only_pdfs.yml');
mockGitHubResponseChangedFiles('foo.pdf');

await run();

expect(updateLabelsMock).toHaveBeenCalledTimes(0); // No labels are created
expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued
});

test.each([
[new HttpError('Error message')],
[new NotFound('Error message')]
Expand Down Expand Up @@ -502,7 +577,28 @@ function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void {
});
}

let mockRepoLabels: Array<{
name: string;
color?: string;
description?: string;
}> = [];

function mockGitHubResponseChangedFiles(...files: string[]): void {
const returnValue = files.map(f => ({filename: f}));
paginateMock.mockReturnValue(<any>returnValue);
mockRepoLabels = [];
paginateMock.mockImplementation((options: any) => {
if (options?.__labelerMock === 'listFiles') {
return returnValue as any;
}
if (options?.__labelerMock === 'listLabelsForRepo') {
return mockRepoLabels as any;
}
throw new Error('Unexpected paginate options in test');
});
}

function mockGitHubResponseRepoLabels(
labels: Array<{name: string; color?: string; description?: string}>
): void {
mockRepoLabels = labels;
}
Loading