Skip to content

Commit

Permalink
Merge pull request #203 from joshdales/main
Browse files Browse the repository at this point in the history
Assigns labels based on branch names
  • Loading branch information
MaksimZhukov committed May 24, 2023
2 parents 2713f73 + 3352df1 commit e1fcf6f
Show file tree
Hide file tree
Showing 15 changed files with 1,376 additions and 223 deletions.
85 changes: 61 additions & 24 deletions README.md
Expand Up @@ -2,84 +2,121 @@

[![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)

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

## Usage

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

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.
Create a `.github/labeler.yml` file with a list of labels and config options to match and apply the label.

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.
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.

#### Match Object

For more control over matching, you can provide a match object instead of a simple path glob. The match object is defined as:
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.

The base match object is defined as:
```yml
- any: ['list', 'of', 'globs']
all: ['list', 'of', 'globs']
- changed-files: ['list', 'of', 'globs']
- base-branch: ['list', 'of', 'regexps']
- head-branch: ['list', 'of', 'regexps']
```

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.
There are two top level keys of `any` and `all`, which both accept the same config options:
```yml
- any:
- changed-files: ['list', 'of', 'globs']
- base-branch: ['list', 'of', 'regexps']
- head-branch: ['list', 'of', 'regexps']
- all:
- changed-files: ['list', 'of', 'globs']
- base-branch: ['list', 'of', 'regexps']
- head-branch: ['list', 'of', 'regexps']
```

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

A simple path glob is the equivalent to `any: ['glob']`. More specifically, the following two configurations are equivalent:
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
label1:
- example1/*
- changed-files: example1/*
```
and
```yml
label1:
- any: ['example1/*']
- any:
- changed-files: ['example1/*']
```

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.
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.

#### Basic Examples

```yml
# Add 'label1' to any changes within 'example' folder or any subfolders
label1:
- example/**/*
- changed-files: example/**/*

# Add 'label2' to any file changes within 'example2' folder
label2: example2/*
label2:
- changed-files: example2/*

# Add label3 to any change to .txt files within the entire repository. Quotation marks are required for the leading asterisk
label3:
- '**/*.txt'
- changed-files: '**/*.txt'

# Add 'label4' to any PR where the head branch name starts with 'example4'
label4:
- head-branch: '^example4'

# Add 'label5' to any PR where the base branch name starts with 'example5'
label5:
- base-branch: '^example5'
```

#### Common Examples

```yml
# Add 'repo' label to any root file changes
repo:
- '*'
- changed-files: '*'

# Add '@domain/core' label to any change within the 'core' package
'@domain/core':
- package/core/*
- package/core/**/*
- changed-files:
- package/core/*
- package/core/**/*

# Add 'test' label to any change to *.spec.js files within the source dir
test:
- src/**/*.spec.js
- changed-files: src/**/*.spec.js

# Add 'source' label to any change to src files within the source dir EXCEPT for the docs sub-folder
source:
- any: ['src/**/*', '!src/docs/*']
- changed-files:
- any: ['src/**/*', '!src/docs/*']

# Add 'frontend` label to any change to *.js files as long as the `main.js` hasn't changed
frontend:
- any: ['src/**/*.js']
all: ['!src/main.js']
- any:
- changed-files: ['src/**/*.js']
- all:
- changed-files: ['!src/main.js']

# Add 'feature' label to any PR where the head branch name starts with `feature` or has a `feature` section in the name
feature:
- head-branch: ['^feature', 'feature']

# Add 'release' label to any PR that is opened against the `main` branch
release:
- base-branch: 'main'
```

### Create Workflow
Expand All @@ -98,7 +135,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
- uses: actions/labeler@v5
```

#### Inputs
Expand Down
8 changes: 7 additions & 1 deletion __mocks__/@actions/github.ts
@@ -1,7 +1,13 @@
export const context = {
payload: {
pull_request: {
number: 123
number: 123,
head: {
ref: 'head-branch-name'
},
base: {
ref: 'base-branch-name'
}
}
},
repo: {
Expand Down
210 changes: 210 additions & 0 deletions __tests__/branch.test.ts
@@ -0,0 +1,210 @@
import {
getBranchName,
checkAnyBranch,
checkAllBranch,
toBranchMatchConfig,
BranchMatchConfig
} from '../src/branch';
import * as github from '@actions/github';

jest.mock('@actions/core');
jest.mock('@actions/github');

describe('getBranchName', () => {
describe('when the pull requests base branch is requested', () => {
it('returns the base branch name', () => {
const result = getBranchName('base');
expect(result).toEqual('base-branch-name');
});
});

describe('when the pull requests head branch is requested', () => {
it('returns the head branch name', () => {
const result = getBranchName('head');
expect(result).toEqual('head-branch-name');
});
});
});

describe('checkAllBranch', () => {
beforeEach(() => {
github.context.payload.pull_request!.head = {
ref: 'test/feature/123'
};
github.context.payload.pull_request!.base = {
ref: 'main'
};
});

describe('when a single pattern is provided', () => {
describe('and the pattern matches the head branch', () => {
it('returns true', () => {
const result = checkAllBranch(['^test'], 'head');
expect(result).toBe(true);
});
});

describe('and the pattern does not match the head branch', () => {
it('returns false', () => {
const result = checkAllBranch(['^feature/'], 'head');
expect(result).toBe(false);
});
});
});

describe('when multiple patterns are provided', () => {
describe('and not all patterns matched', () => {
it('returns false', () => {
const result = checkAllBranch(['^test/', '^feature/'], 'head');
expect(result).toBe(false);
});
});

describe('and all patterns match', () => {
it('returns true', () => {
const result = checkAllBranch(['^test/', '/feature/'], 'head');
expect(result).toBe(true);
});
});

describe('and no patterns match', () => {
it('returns false', () => {
const result = checkAllBranch(['^feature/', '/test$'], 'head');
expect(result).toBe(false);
});
});
});

describe('when the branch to check is specified as the base branch', () => {
describe('and the pattern matches the base branch', () => {
it('returns true', () => {
const result = checkAllBranch(['^main$'], 'base');
expect(result).toBe(true);
});
});
});
});

describe('checkAnyBranch', () => {
beforeEach(() => {
github.context.payload.pull_request!.head = {
ref: 'test/feature/123'
};
github.context.payload.pull_request!.base = {
ref: 'main'
};
});

describe('when a single pattern is provided', () => {
describe('and the pattern matches the head branch', () => {
it('returns true', () => {
const result = checkAnyBranch(['^test'], 'head');
expect(result).toBe(true);
});
});

describe('and the pattern does not match the head branch', () => {
it('returns false', () => {
const result = checkAnyBranch(['^feature/'], 'head');
expect(result).toBe(false);
});
});
});

describe('when multiple patterns are provided', () => {
describe('and at least one pattern matches', () => {
it('returns true', () => {
const result = checkAnyBranch(['^test/', '^feature/'], 'head');
expect(result).toBe(true);
});
});

describe('and all patterns match', () => {
it('returns true', () => {
const result = checkAnyBranch(['^test/', '/feature/'], 'head');
expect(result).toBe(true);
});
});

describe('and no patterns match', () => {
it('returns false', () => {
const result = checkAnyBranch(['^feature/', '/test$'], 'head');
expect(result).toBe(false);
});
});
});

describe('when the branch to check is specified as the base branch', () => {
describe('and the pattern matches the base branch', () => {
it('returns true', () => {
const result = checkAnyBranch(['^main$'], 'base');
expect(result).toBe(true);
});
});
});
});

describe('toBranchMatchConfig', () => {
describe('when there are no branch keys in the config', () => {
const config = {'changed-files': [{any: ['testing']}]};

it('returns an empty object', () => {
const result = toBranchMatchConfig(config);
expect(result).toEqual({});
});
});

describe('when the config contains a head-branch option', () => {
const config = {'head-branch': ['testing']};

it('sets headBranch in the matchConfig', () => {
const result = toBranchMatchConfig(config);
expect(result).toEqual<BranchMatchConfig>({
headBranch: ['testing']
});
});

describe('and the matching option is a string', () => {
const stringConfig = {'head-branch': 'testing'};

it('sets headBranch in the matchConfig', () => {
const result = toBranchMatchConfig(stringConfig);
expect(result).toEqual<BranchMatchConfig>({
headBranch: ['testing']
});
});
});
});

describe('when the config contains a base-branch option', () => {
const config = {'base-branch': ['testing']};
it('sets baseBranch in the matchConfig', () => {
const result = toBranchMatchConfig(config);
expect(result).toEqual<BranchMatchConfig>({
baseBranch: ['testing']
});
});

describe('and the matching option is a string', () => {
const stringConfig = {'base-branch': 'testing'};

it('sets baseBranch in the matchConfig', () => {
const result = toBranchMatchConfig(stringConfig);
expect(result).toEqual<BranchMatchConfig>({
baseBranch: ['testing']
});
});
});
});

describe('when the config contains both a base-branch and head-branch option', () => {
const config = {'base-branch': ['testing'], 'head-branch': ['testing']};
it('sets headBranch and baseBranch in the matchConfig', () => {
const result = toBranchMatchConfig(config);
expect(result).toEqual<BranchMatchConfig>({
baseBranch: ['testing'],
headBranch: ['testing']
});
});
});
});

0 comments on commit e1fcf6f

Please sign in to comment.