Skip to content

Commit e1fcf6f

Browse files
authored
Merge pull request #203 from joshdales/main
Assigns labels based on branch names
2 parents 2713f73 + 3352df1 commit e1fcf6f

15 files changed

+1376
-223
lines changed

README.md

+61-24
Original file line numberDiff line numberDiff line change
@@ -2,84 +2,121 @@
22

33
[![Basic validation](https://github.com/actions/labeler/actions/workflows/basic-validation.yml/badge.svg?branch=main)](https://github.com/actions/labeler/actions/workflows/basic-validation.yml)
44

5-
Automatically label new pull requests based on the paths of files being changed.
5+
Automatically label new pull requests based on the paths of files being changed or the branch name.
66

77
## Usage
88

99
### Create `.github/labeler.yml`
1010

11-
Create a `.github/labeler.yml` file with a list of labels and [minimatch](https://github.com/isaacs/minimatch) globs to match to apply the label.
11+
Create a `.github/labeler.yml` file with a list of labels and config options to match and apply the label.
1212

13-
The key is the name of the label in your repository that you want to add (eg: "merge conflict", "needs-updating") and the value is the path (glob) of the changed files (eg: `src/**/*`, `tests/*.spec.js`) or a match object.
13+
The key is the name of the label in your repository that you want to add (eg: "merge conflict", "needs-updating") and the value is a match object.
1414

1515
#### Match Object
1616

17-
For more control over matching, you can provide a match object instead of a simple path glob. The match object is defined as:
17+
The match object allows control over the matching options, you can specify the label to be applied based on the files that have changed or the name of either the base branch or the head branch. For the changed files options you provide a [path glob](https://github.com/isaacs/minimatch#minimatch), and for the branches you provide a regexp to match against the branch name.
1818

19+
The base match object is defined as:
1920
```yml
20-
- any: ['list', 'of', 'globs']
21-
all: ['list', 'of', 'globs']
21+
- changed-files: ['list', 'of', 'globs']
22+
- base-branch: ['list', 'of', 'regexps']
23+
- head-branch: ['list', 'of', 'regexps']
2224
```
2325
24-
One or both fields can be provided for fine-grained matching. Unlike the top-level list, the list of path globs provided to `any` and `all` must ALL match against a path for the label to be applied.
26+
There are two top level keys of `any` and `all`, which both accept the same config options:
27+
```yml
28+
- any:
29+
- changed-files: ['list', 'of', 'globs']
30+
- base-branch: ['list', 'of', 'regexps']
31+
- head-branch: ['list', 'of', 'regexps']
32+
- all:
33+
- changed-files: ['list', 'of', 'globs']
34+
- base-branch: ['list', 'of', 'regexps']
35+
- head-branch: ['list', 'of', 'regexps']
36+
```
2537

38+
One or all fields can be provided for fine-grained matching.
2639
The fields are defined as follows:
27-
* `any`: match ALL globs against ANY changed path
28-
* `all`: match ALL globs against ALL changed paths
40+
* `all`: all of the provided options must match in order for the label to be applied
41+
* `any`: if any of the provided options match then a label will be applied
42+
* `base-branch`: match regexps against the base branch name
43+
* `changed-files`: match glob patterns against the changed paths
44+
* `head-branch`: match regexps against the head branch name
2945

30-
A simple path glob is the equivalent to `any: ['glob']`. More specifically, the following two configurations are equivalent:
46+
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:
3147
```yml
3248
label1:
33-
- example1/*
49+
- changed-files: example1/*
3450
```
3551
and
3652
```yml
3753
label1:
38-
- any: ['example1/*']
54+
- any:
55+
- changed-files: ['example1/*']
3956
```
4057

41-
From a boolean logic perspective, top-level match objects are `OR`-ed together and individual match rules within an object are `AND`-ed. Combined with `!` negation, you can write complex matching rules.
58+
From a boolean logic perspective, top-level match objects, and options within `all` are `AND`-ed together and individual match rules within the `any` object are `OR`-ed. If path globs are combined with `!` negation, you can write complex matching rules.
4259

4360
#### Basic Examples
4461

4562
```yml
4663
# Add 'label1' to any changes within 'example' folder or any subfolders
4764
label1:
48-
- example/**/*
65+
- changed-files: example/**/*
4966
5067
# Add 'label2' to any file changes within 'example2' folder
51-
label2: example2/*
68+
label2:
69+
- changed-files: example2/*
5270
5371
# Add label3 to any change to .txt files within the entire repository. Quotation marks are required for the leading asterisk
5472
label3:
55-
- '**/*.txt'
73+
- changed-files: '**/*.txt'
74+
75+
# Add 'label4' to any PR where the head branch name starts with 'example4'
76+
label4:
77+
- head-branch: '^example4'
5678
79+
# Add 'label5' to any PR where the base branch name starts with 'example5'
80+
label5:
81+
- base-branch: '^example5'
5782
```
5883

5984
#### Common Examples
6085

6186
```yml
6287
# Add 'repo' label to any root file changes
6388
repo:
64-
- '*'
89+
- changed-files: '*'
6590
6691
# Add '@domain/core' label to any change within the 'core' package
6792
'@domain/core':
68-
- package/core/*
69-
- package/core/**/*
93+
- changed-files:
94+
- package/core/*
95+
- package/core/**/*
7096
7197
# Add 'test' label to any change to *.spec.js files within the source dir
7298
test:
73-
- src/**/*.spec.js
99+
- changed-files: src/**/*.spec.js
74100
75101
# Add 'source' label to any change to src files within the source dir EXCEPT for the docs sub-folder
76102
source:
77-
- any: ['src/**/*', '!src/docs/*']
103+
- changed-files:
104+
- any: ['src/**/*', '!src/docs/*']
78105
79106
# Add 'frontend` label to any change to *.js files as long as the `main.js` hasn't changed
80107
frontend:
81-
- any: ['src/**/*.js']
82-
all: ['!src/main.js']
108+
- any:
109+
- changed-files: ['src/**/*.js']
110+
- all:
111+
- changed-files: ['!src/main.js']
112+
113+
# Add 'feature' label to any PR where the head branch name starts with `feature` or has a `feature` section in the name
114+
feature:
115+
- head-branch: ['^feature', 'feature']
116+
117+
# Add 'release' label to any PR that is opened against the `main` branch
118+
release:
119+
- base-branch: 'main'
83120
```
84121
85122
### Create Workflow
@@ -98,7 +135,7 @@ jobs:
98135
pull-requests: write
99136
runs-on: ubuntu-latest
100137
steps:
101-
- uses: actions/labeler@v4
138+
- uses: actions/labeler@v5
102139
```
103140

104141
#### Inputs

__mocks__/@actions/github.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
export const context = {
22
payload: {
33
pull_request: {
4-
number: 123
4+
number: 123,
5+
head: {
6+
ref: 'head-branch-name'
7+
},
8+
base: {
9+
ref: 'base-branch-name'
10+
}
511
}
612
},
713
repo: {

__tests__/branch.test.ts

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import {
2+
getBranchName,
3+
checkAnyBranch,
4+
checkAllBranch,
5+
toBranchMatchConfig,
6+
BranchMatchConfig
7+
} from '../src/branch';
8+
import * as github from '@actions/github';
9+
10+
jest.mock('@actions/core');
11+
jest.mock('@actions/github');
12+
13+
describe('getBranchName', () => {
14+
describe('when the pull requests base branch is requested', () => {
15+
it('returns the base branch name', () => {
16+
const result = getBranchName('base');
17+
expect(result).toEqual('base-branch-name');
18+
});
19+
});
20+
21+
describe('when the pull requests head branch is requested', () => {
22+
it('returns the head branch name', () => {
23+
const result = getBranchName('head');
24+
expect(result).toEqual('head-branch-name');
25+
});
26+
});
27+
});
28+
29+
describe('checkAllBranch', () => {
30+
beforeEach(() => {
31+
github.context.payload.pull_request!.head = {
32+
ref: 'test/feature/123'
33+
};
34+
github.context.payload.pull_request!.base = {
35+
ref: 'main'
36+
};
37+
});
38+
39+
describe('when a single pattern is provided', () => {
40+
describe('and the pattern matches the head branch', () => {
41+
it('returns true', () => {
42+
const result = checkAllBranch(['^test'], 'head');
43+
expect(result).toBe(true);
44+
});
45+
});
46+
47+
describe('and the pattern does not match the head branch', () => {
48+
it('returns false', () => {
49+
const result = checkAllBranch(['^feature/'], 'head');
50+
expect(result).toBe(false);
51+
});
52+
});
53+
});
54+
55+
describe('when multiple patterns are provided', () => {
56+
describe('and not all patterns matched', () => {
57+
it('returns false', () => {
58+
const result = checkAllBranch(['^test/', '^feature/'], 'head');
59+
expect(result).toBe(false);
60+
});
61+
});
62+
63+
describe('and all patterns match', () => {
64+
it('returns true', () => {
65+
const result = checkAllBranch(['^test/', '/feature/'], 'head');
66+
expect(result).toBe(true);
67+
});
68+
});
69+
70+
describe('and no patterns match', () => {
71+
it('returns false', () => {
72+
const result = checkAllBranch(['^feature/', '/test$'], 'head');
73+
expect(result).toBe(false);
74+
});
75+
});
76+
});
77+
78+
describe('when the branch to check is specified as the base branch', () => {
79+
describe('and the pattern matches the base branch', () => {
80+
it('returns true', () => {
81+
const result = checkAllBranch(['^main$'], 'base');
82+
expect(result).toBe(true);
83+
});
84+
});
85+
});
86+
});
87+
88+
describe('checkAnyBranch', () => {
89+
beforeEach(() => {
90+
github.context.payload.pull_request!.head = {
91+
ref: 'test/feature/123'
92+
};
93+
github.context.payload.pull_request!.base = {
94+
ref: 'main'
95+
};
96+
});
97+
98+
describe('when a single pattern is provided', () => {
99+
describe('and the pattern matches the head branch', () => {
100+
it('returns true', () => {
101+
const result = checkAnyBranch(['^test'], 'head');
102+
expect(result).toBe(true);
103+
});
104+
});
105+
106+
describe('and the pattern does not match the head branch', () => {
107+
it('returns false', () => {
108+
const result = checkAnyBranch(['^feature/'], 'head');
109+
expect(result).toBe(false);
110+
});
111+
});
112+
});
113+
114+
describe('when multiple patterns are provided', () => {
115+
describe('and at least one pattern matches', () => {
116+
it('returns true', () => {
117+
const result = checkAnyBranch(['^test/', '^feature/'], 'head');
118+
expect(result).toBe(true);
119+
});
120+
});
121+
122+
describe('and all patterns match', () => {
123+
it('returns true', () => {
124+
const result = checkAnyBranch(['^test/', '/feature/'], 'head');
125+
expect(result).toBe(true);
126+
});
127+
});
128+
129+
describe('and no patterns match', () => {
130+
it('returns false', () => {
131+
const result = checkAnyBranch(['^feature/', '/test$'], 'head');
132+
expect(result).toBe(false);
133+
});
134+
});
135+
});
136+
137+
describe('when the branch to check is specified as the base branch', () => {
138+
describe('and the pattern matches the base branch', () => {
139+
it('returns true', () => {
140+
const result = checkAnyBranch(['^main$'], 'base');
141+
expect(result).toBe(true);
142+
});
143+
});
144+
});
145+
});
146+
147+
describe('toBranchMatchConfig', () => {
148+
describe('when there are no branch keys in the config', () => {
149+
const config = {'changed-files': [{any: ['testing']}]};
150+
151+
it('returns an empty object', () => {
152+
const result = toBranchMatchConfig(config);
153+
expect(result).toEqual({});
154+
});
155+
});
156+
157+
describe('when the config contains a head-branch option', () => {
158+
const config = {'head-branch': ['testing']};
159+
160+
it('sets headBranch in the matchConfig', () => {
161+
const result = toBranchMatchConfig(config);
162+
expect(result).toEqual<BranchMatchConfig>({
163+
headBranch: ['testing']
164+
});
165+
});
166+
167+
describe('and the matching option is a string', () => {
168+
const stringConfig = {'head-branch': 'testing'};
169+
170+
it('sets headBranch in the matchConfig', () => {
171+
const result = toBranchMatchConfig(stringConfig);
172+
expect(result).toEqual<BranchMatchConfig>({
173+
headBranch: ['testing']
174+
});
175+
});
176+
});
177+
});
178+
179+
describe('when the config contains a base-branch option', () => {
180+
const config = {'base-branch': ['testing']};
181+
it('sets baseBranch in the matchConfig', () => {
182+
const result = toBranchMatchConfig(config);
183+
expect(result).toEqual<BranchMatchConfig>({
184+
baseBranch: ['testing']
185+
});
186+
});
187+
188+
describe('and the matching option is a string', () => {
189+
const stringConfig = {'base-branch': 'testing'};
190+
191+
it('sets baseBranch in the matchConfig', () => {
192+
const result = toBranchMatchConfig(stringConfig);
193+
expect(result).toEqual<BranchMatchConfig>({
194+
baseBranch: ['testing']
195+
});
196+
});
197+
});
198+
});
199+
200+
describe('when the config contains both a base-branch and head-branch option', () => {
201+
const config = {'base-branch': ['testing'], 'head-branch': ['testing']};
202+
it('sets headBranch and baseBranch in the matchConfig', () => {
203+
const result = toBranchMatchConfig(config);
204+
expect(result).toEqual<BranchMatchConfig>({
205+
baseBranch: ['testing'],
206+
headBranch: ['testing']
207+
});
208+
});
209+
});
210+
});

0 commit comments

Comments
 (0)