Skip to content

Commit fedd507

Browse files
committed
Add color and description metadata settings to labels
1 parent 77a4082 commit fedd507

File tree

10 files changed

+18254
-17351
lines changed

10 files changed

+18254
-17351
lines changed

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ The base match object is defined as:
4444
- all-globs-to-all-files: ['list', 'of', 'globs']
4545
- base-branch: ['list', 'of', 'regexps']
4646
- head-branch: ['list', 'of', 'regexps']
47+
- description: 'Description of label'
48+
- color: 'Color of label'
4749
```
4850
4951
There are two top-level keys, `any` and `all`, which both accept the same configuration options:
@@ -79,6 +81,8 @@ The fields are defined as follows:
7981
- `any-glob-to-all-files`: ANY glob must match against ALL changed files
8082
- `all-globs-to-any-file`: ALL globs must match against ANY changed file
8183
- `all-globs-to-all-files`: ALL globs must match against ALL changed files
84+
- `description`: The description of the label if creating it
85+
- `color`: The color of the label if creating it (6-character hex, with or without `#`)
8286

8387
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:
8488
```yml
@@ -151,6 +155,13 @@ feature:
151155
# Add 'release' label to any PR that is opened against the `main` branch
152156
release:
153157
- base-branch: 'main'
158+
159+
# Define label properties
160+
created-label:
161+
- changed-files:
162+
- any-glob-to-any-file: '*'
163+
- description: "This label was created if it didn't already exist"
164+
- color: "abcdef"
154165
```
155166
156167
### Create Workflow
@@ -319,4 +330,4 @@ Once you confirm that the updated configuration files function as intended, you
319330

320331
## Contributions
321332

322-
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
333+
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).

__mocks__/@actions/github.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ export const context = {
1919
const mockApi = {
2020
rest: {
2121
issues: {
22-
setLabels: jest.fn()
22+
setLabels: jest.fn(),
23+
updateLabel: jest.fn(),
24+
createLabel: jest.fn(),
25+
listLabelsForRepo: {
26+
endpoint: {
27+
merge: jest.fn().mockReturnValue({
28+
__labelerMock: 'listLabelsForRepo'
29+
})
30+
}
31+
}
2332
},
2433
pulls: {
2534
get: jest.fn().mockResolvedValue({
@@ -29,7 +38,9 @@ const mockApi = {
2938
}),
3039
listFiles: {
3140
endpoint: {
32-
merge: jest.fn().mockReturnValue({})
41+
merge: jest.fn().mockReturnValue({
42+
__labelerMock: 'listFiles'
43+
})
3344
}
3445
}
3546
},

__tests__/fixtures/all_options.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ label1:
99
- all-globs-to-all-files: ['glob']
1010
- head-branch: ['regexp']
1111
- base-branch: ['regexp']
12+
- description: 'Label1 description'
13+
- color: 'ff00ff'
1214

1315
label2:
1416
- changed-files:
1517
- any-glob-to-any-file: ['glob']
1618
- head-branch: ['regexp']
1719
- base-branch: ['regexp']
20+
- description: 'Label2 description'
21+
- color: 'ffff00'

__tests__/fixtures/label_meta.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
label1:
2+
- any:
3+
- changed-files:
4+
- any-glob-to-any-file: ['tests/**/*']
5+
6+
label2:
7+
- changed-files:
8+
- any-glob-to-any-file: ['tests/**/*']
9+
- description: 'Label2 description'
10+
- color: 'ff00ff'
11+
12+
label3:
13+
- changed-files:
14+
- any-glob-to-any-file: ['tests/**/*']
15+
- description: 'Label3 description'
16+
17+
label4:
18+
- changed-files:
19+
- any-glob-to-any-file: ['tests/**/*']
20+
- color: '#000000'

__tests__/labeler.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import {checkMatchConfigs} from '../src/labeler';
88
import {
99
MatchConfig,
1010
toMatchConfig,
11+
toLabelConfig,
1112
getLabelConfigMapFromObject,
1213
BaseMatchConfig
1314
} from '../src/api/get-label-configs';
15+
import {updateLabels} from '../src/api/set-labels';
1416

1517
jest.mock('@actions/core');
1618
jest.mock('../src/api');
@@ -44,6 +46,12 @@ describe('getLabelConfigMapFromObject', () => {
4446
{baseBranch: undefined, headBranch: ['regexp']},
4547
{baseBranch: ['regexp'], headBranch: undefined}
4648
]
49+
},
50+
{
51+
meta: {
52+
description: 'Label1 description',
53+
color: 'ff00ff'
54+
}
4755
}
4856
]);
4957
expected.set('label2', [
@@ -53,6 +61,12 @@ describe('getLabelConfigMapFromObject', () => {
5361
{baseBranch: undefined, headBranch: ['regexp']},
5462
{baseBranch: ['regexp'], headBranch: undefined}
5563
]
64+
},
65+
{
66+
meta: {
67+
description: 'Label2 description',
68+
color: 'ffff00'
69+
}
5670
}
5771
]);
5872

@@ -91,6 +105,24 @@ describe('toMatchConfig', () => {
91105
});
92106
});
93107

108+
describe('toLabelConfig', () => {
109+
it('normalizes color values and accepts # prefixes', () => {
110+
const warningSpy = jest.spyOn(core, 'warning').mockImplementation();
111+
const result = toLabelConfig({color: '#ff00ff'});
112+
expect(result).toEqual({color: 'ff00ff'});
113+
expect(warningSpy).not.toHaveBeenCalled();
114+
warningSpy.mockRestore();
115+
});
116+
117+
it('warns and drops invalid color values', () => {
118+
const warningSpy = jest.spyOn(core, 'warning').mockImplementation();
119+
const result = toLabelConfig({color: '#fff'});
120+
expect(result).toEqual({});
121+
expect(warningSpy).toHaveBeenCalledTimes(1);
122+
warningSpy.mockRestore();
123+
});
124+
});
125+
94126
describe('checkMatchConfigs', () => {
95127
describe('when a single match config is provided', () => {
96128
const matchConfig: MatchConfig[] = [
@@ -233,3 +265,81 @@ describe('labeler error handling', () => {
233265
expect(core.setFailed).toHaveBeenCalledWith(error.message);
234266
});
235267
});
268+
269+
describe('updateLabels', () => {
270+
const gh = github.getOctokit('_');
271+
const updateLabelMock = jest.spyOn(gh.rest.issues, 'updateLabel');
272+
const createLabelMock = jest.spyOn(gh.rest.issues, 'createLabel');
273+
const paginateMock = jest.spyOn(gh, 'paginate');
274+
275+
const buildLabelConfigs = (
276+
meta: MatchConfig['meta']
277+
): Map<string, MatchConfig[]> => new Map([['label1', [{meta}]]]);
278+
279+
beforeEach(() => {
280+
jest.clearAllMocks();
281+
});
282+
283+
it('updates existing labels when metadata differs', async () => {
284+
paginateMock.mockResolvedValue([
285+
{name: 'label1', color: '000000', description: 'old'}
286+
]);
287+
288+
const labelConfigs = buildLabelConfigs({
289+
color: 'ff00ff',
290+
description: 'new'
291+
});
292+
const repoLabelCache = new Map();
293+
294+
await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);
295+
296+
expect(updateLabelMock).toHaveBeenCalledTimes(1);
297+
expect(updateLabelMock).toHaveBeenCalledWith({
298+
owner: 'monalisa',
299+
repo: 'helloworld',
300+
name: 'label1',
301+
color: 'ff00ff',
302+
description: 'new'
303+
});
304+
expect(createLabelMock).toHaveBeenCalledTimes(0);
305+
});
306+
307+
it('does not update labels when metadata matches', async () => {
308+
paginateMock.mockResolvedValue([
309+
{name: 'label1', color: 'ff00ff', description: 'same'}
310+
]);
311+
312+
const labelConfigs = buildLabelConfigs({
313+
color: 'ff00ff',
314+
description: 'same'
315+
});
316+
const repoLabelCache = new Map();
317+
318+
await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);
319+
320+
expect(updateLabelMock).toHaveBeenCalledTimes(0);
321+
expect(createLabelMock).toHaveBeenCalledTimes(0);
322+
});
323+
324+
it('creates labels when missing from the repository', async () => {
325+
paginateMock.mockResolvedValue([]);
326+
327+
const labelConfigs = buildLabelConfigs({
328+
color: 'ff00ff',
329+
description: 'new'
330+
});
331+
const repoLabelCache = new Map();
332+
333+
await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache);
334+
335+
expect(createLabelMock).toHaveBeenCalledTimes(1);
336+
expect(createLabelMock).toHaveBeenCalledWith({
337+
owner: 'monalisa',
338+
repo: 'helloworld',
339+
name: 'label1',
340+
color: 'ff00ff',
341+
description: 'new'
342+
});
343+
expect(updateLabelMock).toHaveBeenCalledTimes(0);
344+
});
345+
});

__tests__/main.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ jest.mock('@actions/github');
99

1010
const gh = github.getOctokit('_');
1111
const setLabelsMock = jest.spyOn(gh.rest.issues, 'setLabels');
12+
const updateLabelsMock = jest.spyOn(gh.rest.issues, 'updateLabel');
13+
const createLabelsMock = jest.spyOn(gh.rest.issues, 'createLabel');
1214
const reposMock = jest.spyOn(gh.rest.repos, 'getContent');
1315
const paginateMock = jest.spyOn(gh, 'paginate');
1416
const getPullMock = jest.spyOn(gh.rest.pulls, 'get');
@@ -37,7 +39,8 @@ const yamlFixtures = {
3739
'branches.yml': fs.readFileSync('__tests__/fixtures/branches.yml'),
3840
'only_pdfs.yml': fs.readFileSync('__tests__/fixtures/only_pdfs.yml'),
3941
'not_supported.yml': fs.readFileSync('__tests__/fixtures/not_supported.yml'),
40-
'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml')
42+
'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml'),
43+
'label_meta.yml': fs.readFileSync('__tests__/fixtures/label_meta.yml')
4144
};
4245

4346
const configureInput = (
@@ -471,6 +474,78 @@ describe('run', () => {
471474
expect(reposMock).toHaveBeenCalled();
472475
});
473476

477+
it('creates missing labels with metadata', async () => {
478+
configureInput({
479+
'repo-token': 'foo',
480+
'configuration-path': 'bar'
481+
});
482+
483+
usingLabelerConfigYaml('label_meta.yml');
484+
mockGitHubResponseChangedFiles('tests/test.txt');
485+
mockGitHubResponseRepoLabels([]);
486+
getPullMock.mockResolvedValue(<any>{
487+
data: {
488+
labels: []
489+
}
490+
});
491+
492+
await run();
493+
494+
expect(setLabelsMock).toHaveBeenCalledTimes(1);
495+
expect(setLabelsMock).toHaveBeenCalledWith({
496+
owner: 'monalisa',
497+
repo: 'helloworld',
498+
issue_number: 123,
499+
labels: ['label1', 'label2', 'label3', 'label4']
500+
});
501+
502+
expect(createLabelsMock).toHaveBeenCalledTimes(3);
503+
expect(createLabelsMock).toHaveBeenCalledWith({
504+
owner: 'monalisa',
505+
repo: 'helloworld',
506+
name: 'label2',
507+
color: 'ff00ff',
508+
description: 'Label2 description'
509+
});
510+
expect(createLabelsMock).toHaveBeenCalledWith({
511+
owner: 'monalisa',
512+
repo: 'helloworld',
513+
name: 'label3',
514+
description: 'Label3 description'
515+
});
516+
expect(createLabelsMock).toHaveBeenCalledWith({
517+
owner: 'monalisa',
518+
repo: 'helloworld',
519+
name: 'label4',
520+
color: '000000'
521+
});
522+
expect(updateLabelsMock).toHaveBeenCalledTimes(0);
523+
expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued
524+
});
525+
526+
it('does not create labels or issue warnings if all labels exist', async () => {
527+
configureInput({
528+
'repo-token': 'foo',
529+
'configuration-path': 'bar'
530+
});
531+
532+
usingLabelerConfigYaml('only_pdfs.yml');
533+
mockGitHubResponseChangedFiles('foo.pdf');
534+
getPullMock.mockResolvedValue(<any>{
535+
data: {
536+
labels: [{name: 'touched-a-pdf-file'}]
537+
}
538+
});
539+
540+
usingLabelerConfigYaml('only_pdfs.yml');
541+
mockGitHubResponseChangedFiles('foo.pdf');
542+
543+
await run();
544+
545+
expect(updateLabelsMock).toHaveBeenCalledTimes(0); // No labels are created
546+
expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued
547+
});
548+
474549
test.each([
475550
[new HttpError('Error message')],
476551
[new NotFound('Error message')]
@@ -502,7 +577,28 @@ function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void {
502577
});
503578
}
504579

580+
let mockRepoLabels: Array<{
581+
name: string;
582+
color?: string;
583+
description?: string;
584+
}> = [];
585+
505586
function mockGitHubResponseChangedFiles(...files: string[]): void {
506587
const returnValue = files.map(f => ({filename: f}));
507-
paginateMock.mockReturnValue(<any>returnValue);
588+
mockRepoLabels = [];
589+
paginateMock.mockImplementation((options: any) => {
590+
if (options?.__labelerMock === 'listFiles') {
591+
return returnValue as any;
592+
}
593+
if (options?.__labelerMock === 'listLabelsForRepo') {
594+
return mockRepoLabels as any;
595+
}
596+
throw new Error('Unexpected paginate options in test');
597+
});
598+
}
599+
600+
function mockGitHubResponseRepoLabels(
601+
labels: Array<{name: string; color?: string; description?: string}>
602+
): void {
603+
mockRepoLabels = labels;
508604
}

0 commit comments

Comments
 (0)