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
54 changes: 45 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The match object allows control over the matching options. You can specify the l

The base match object is defined as:
```yml
- changed-files:
- changed-files:
- any-glob-to-any-file: ['list', 'of', 'globs']
- any-glob-to-all-files: ['list', 'of', 'globs']
- all-globs-to-any-file: ['list', 'of', 'globs']
Expand Down Expand Up @@ -132,7 +132,7 @@ Documentation:
- changed-files:
- any-glob-to-any-file: ['docs/*', 'guides/*']

# Add 'Documentation' label to any change to .md files within the entire repository
# Add 'Documentation' label to any change to .md files within the entire repository
Documentation:
- changed-files:
- any-glob-to-any-file: '**/*.md'
Expand All @@ -153,6 +153,42 @@ release:
- base-branch: 'main'
```

#### Configuration Options

The labeler configuration file (`.github/labeler.yml`) supports the following top-level options:

| Name | Description |
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `changed-files-limit` | Maximum number of labels to apply based on changed files. If exceeded, no changed-files labels are applied. Useful for tree-wide refactors that touch many components. |

##### Example: limiting changed-files labels

When working with large PRs (e.g., tree-wide refactors) that touch many components, you may want to prevent the labeler from adding too many labels. Set `changed-files-limit` in your `.github/labeler.yml` configuration file to limit the number of labels that can be applied based on changed files patterns. If the limit is exceeded, no changed-files labels will be applied, while branch-based labels will still work normally.

```yml
# .github/labeler.yml

# Limit changed-files based labels to 5
changed-files-limit: 5

# Label definitions
frontend:
- changed-files:
- any-glob-to-any-file: 'src/frontend/**'

backend:
- changed-files:
- any-glob-to-any-file: 'src/backend/**'

docs:
- changed-files:
- any-glob-to-any-file: 'docs/**'

# Branch-based labels are not affected by the limit
feature:
- head-branch: '^feature/'
```

### Create Workflow

Create a workflow (e.g. `.github/workflows/labeler.yml` see [Creating a Workflow file](https://docs.github.com/en/actions/writing-workflows/quickstart#creating-your-first-workflow)) to utilize the labeler action with content:
Expand Down Expand Up @@ -213,10 +249,10 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:

# Label PRs 1, 2, and 3
- uses: actions/labeler@v6
with:
with:
pr-number: |
1
2
Expand All @@ -225,9 +261,9 @@ jobs:

**Note:** in normal usage the `pr-number` input is not required as the action will detect the PR number from the workflow context.

#### Outputs
#### Outputs

Labeler provides the following outputs:
Labeler provides the following outputs:

| Name | Description |
|--------------|-----------------------------------------------------------|
Expand All @@ -249,13 +285,13 @@ jobs:
steps:
- id: label-the-PR
uses: actions/labeler@v6

- id: run-frontend-tests
if: contains(steps.label-the-PR.outputs.all-labels, 'frontend')
run: |
echo "Running frontend tests..."
# Put your commands for running frontend tests here

- id: run-backend-tests
if: contains(steps.label-the-PR.outputs.all-labels, 'backend')
run: |
Expand Down Expand Up @@ -291,7 +327,7 @@ To ensure the action works correctly, include the following permissions in your
issues: write
```

### Manual Label Creation as an Alternative to Granting issues write Permission
### Manual Label Creation as an Alternative to Granting issues write Permission

If you prefer not to grant the `issues: write` permission in your workflow, you can manually create all required labels in the repository before the action runs.

Expand Down
18 changes: 18 additions & 0 deletions __tests__/fixtures/limit_0.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Limit to 0 changed-files labels (none allowed)
changed-files-limit: 0

# Labels based on changed files
component-a:
- changed-files:
- any-glob-to-any-file: ['components/a/**']

component-b:
- changed-files:
- any-glob-to-any-file: ['components/b/**']

# Labels based on branch patterns only
test-branch:
- head-branch: '^test/'

feature-branch:
- head-branch: '/feature/'
26 changes: 26 additions & 0 deletions __tests__/fixtures/limit_1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Limit to 1 changed-files label
changed-files-limit: 1

# Labels based on changed files
component-a:
- changed-files:
- any-glob-to-any-file: ['components/a/**']

component-b:
- changed-files:
- any-glob-to-any-file: ['components/b/**']

component-c:
- changed-files:
- any-glob-to-any-file: ['components/c/**']

component-d:
- changed-files:
- any-glob-to-any-file: ['components/d/**']

# Labels based on branch patterns only
test-branch:
- head-branch: '^test/'

feature-branch:
- head-branch: '/feature/'
26 changes: 26 additions & 0 deletions __tests__/fixtures/limit_2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Limit to 2 changed-files labels
changed-files-limit: 2

# Labels based on changed files
component-a:
- changed-files:
- any-glob-to-any-file: ['components/a/**']

component-b:
- changed-files:
- any-glob-to-any-file: ['components/b/**']

component-c:
- changed-files:
- any-glob-to-any-file: ['components/c/**']

component-d:
- changed-files:
- any-glob-to-any-file: ['components/d/**']

# Labels based on branch patterns only
test-branch:
- head-branch: '^test/'

feature-branch:
- head-branch: '/feature/'
26 changes: 26 additions & 0 deletions __tests__/fixtures/limit_3.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Limit to 3 changed-files labels
changed-files-limit: 3

# Labels based on changed files
component-a:
- changed-files:
- any-glob-to-any-file: ['components/a/**']

component-b:
- changed-files:
- any-glob-to-any-file: ['components/b/**']

component-c:
- changed-files:
- any-glob-to-any-file: ['components/c/**']

component-d:
- changed-files:
- any-glob-to-any-file: ['components/d/**']

# Labels based on branch patterns only
test-branch:
- head-branch: '^test/'

feature-branch:
- head-branch: '/feature/'
23 changes: 23 additions & 0 deletions __tests__/fixtures/mixed_labels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Labels based on changed files
component-a:
- changed-files:
- any-glob-to-any-file: ['components/a/**']

component-b:
- changed-files:
- any-glob-to-any-file: ['components/b/**']

component-c:
- changed-files:
- any-glob-to-any-file: ['components/c/**']

component-d:
- changed-files:
- any-glob-to-any-file: ['components/d/**']

# Labels based on branch patterns only
test-branch:
- head-branch: '^test/'

feature-branch:
- head-branch: '/feature/'
125 changes: 121 additions & 4 deletions __tests__/labeler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
MatchConfig,
toMatchConfig,
getLabelConfigMapFromObject,
BaseMatchConfig
getLabelConfigResultFromObject,
BaseMatchConfig,
configUsesChangedFiles
} from '../src/api/get-label-configs';

jest.mock('@actions/core');
Expand Down Expand Up @@ -60,6 +62,74 @@ describe('getLabelConfigMapFromObject', () => {
const result = getLabelConfigMapFromObject(yamlObject);
expect(result).toEqual(expected);
});

it('ignores top-level options like changed-files-limit', () => {
const configWithLimit = {
'changed-files-limit': 5,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigMapFromObject(configWithLimit);
expect(result.has('changed-files-limit')).toBe(false);
expect(result.has('label1')).toBe(true);
});
});

describe('getLabelConfigResultFromObject', () => {
it('extracts changed-files-limit as a number', () => {
const config = {
'changed-files-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-limit from string', () => {
const config = {
'changed-files-limit': '10',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(10);
});

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-limit value', () => {
const config = {
'changed-files-limit': 'invalid',
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
expect(() => getLabelConfigResultFromObject(config)).toThrow(
/Invalid value for 'changed-files-limit'/
);
});

it('throws error for negative changed-files-limit value', () => {
const config = {
'changed-files-limit': -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 changed-files-limit', () => {
const config = {
'changed-files-limit': 0,
label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}]
};
const result = getLabelConfigResultFromObject(config);
expect(result.changedFilesLimit).toBe(0);
});
});

describe('toMatchConfig', () => {
Expand Down Expand Up @@ -164,6 +234,52 @@ describe('checkMatchConfigs', () => {
});
});

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 = {
Expand All @@ -183,9 +299,10 @@ describe('labeler error handling', () => {
}
]);

(api.getLabelConfigs as jest.Mock).mockResolvedValue(
new Map([['new-label', ['dummy-config']]])
);
(api.getLabelConfigs as jest.Mock).mockResolvedValue({
labelConfigs: new Map([['new-label', ['dummy-config']]]),
changedFilesLimit: undefined
});

// Force match so "new-label" is always added
jest.spyOn({checkMatchConfigs}, 'checkMatchConfigs').mockReturnValue(true);
Expand Down
Loading
Loading