1
0
mirror of https://github.com/actions/labeler synced 2026-05-10 21:11:03 +02:00

Add 'changed-files-labels-limit' and 'max-files-changed' configs to allow capping number of labels added (#923)

* README.md: drop trailing whitespace

* Add 'changed-files-labels-limit' config to allow capping number of labels added

When a repository has many components, each with a changed-files label,
a large refactor ends up with the labeler spamming the pull request
with label changes. The end result is not very useful as it's not
very readable, and due to how github automatically hides comments
when label changes overflow the discussion tab, it means useful
information is hidden and one has to manually click "Load more..."
dozens of time every time the page is loaded.

Add a changed-files-labels-limit top level config knob. If more than the
configured limit of labels is set to be added, none are added.
This only affects changed-files labels.

* Add 'max-files-changed' config to allow capping number of files for labelling

When a PR modifies a very large number of files (e.g., tree-wide
refactors, automated code formatting), this new options allows
skipping file-based labeling entirely when the number of files
that are changed hits the configured limit.

Fixes https://github.com/actions/labeler/issues/486
This commit is contained in:
Luca Boccassi
2026-03-26 15:03:12 +00:00
committed by GitHub
parent e52e4fb63e
commit c5dadc2a45
14 changed files with 1129 additions and 25 deletions

View File

@@ -37,7 +37,17 @@ 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'),
'mixed_labels.yml': fs.readFileSync('__tests__/fixtures/mixed_labels.yml'),
'limit_0.yml': fs.readFileSync('__tests__/fixtures/limit_0.yml'),
'limit_1.yml': fs.readFileSync('__tests__/fixtures/limit_1.yml'),
'limit_2.yml': fs.readFileSync('__tests__/fixtures/limit_2.yml'),
'limit_3.yml': fs.readFileSync('__tests__/fixtures/limit_3.yml'),
'mixed_rules.yml': fs.readFileSync('__tests__/fixtures/mixed_rules.yml'),
'max_files_5.yml': fs.readFileSync('__tests__/fixtures/max_files_5.yml'),
'max_files_with_branch.yml': fs.readFileSync(
'__tests__/fixtures/max_files_with_branch.yml'
)
};
const configureInput = (
@@ -440,6 +450,341 @@ describe('run', () => {
expect(setLabelsMock).toHaveBeenCalledTimes(0);
});
describe('changed-files-labels-limit', () => {
it('applies all labels when count is within limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('limit_3.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b']
});
});
it('skips changed-files labels when count exceeds limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('limit_2.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// No labels should be applied since changed-files labels exceed limit
expect(setLabelsMock).toHaveBeenCalledTimes(0);
});
it('still applies branch-based labels when changed-files limit is exceeded', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'test/some-feature'};
usingLabelerConfigYaml('limit_1.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// Only the branch-based label should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['test-branch']
});
});
it('applies all labels when no limit is set', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('mixed_labels.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts',
'components/d/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b', 'component-c', 'component-d']
});
});
it('does not count preexisting labels toward the limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('limit_2.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts',
'components/d/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: [{name: 'component-a'}, {name: 'component-b'}]}
});
await run();
// component-a and component-b are preexisting, so only 2 new labels (c, d) would be added
// which equals the limit of 2, so labels should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b', 'component-c', 'component-d']
});
});
it('skips new labels when new count exceeds limit even with preexisting', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('limit_2.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts',
'components/d/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: [{name: 'component-a'}]}
});
await run();
// component-a is preexisting, so 3 new labels (b, c, d) would be added
// which exceeds the limit of 2, so no new changed-files labels are applied
expect(setLabelsMock).toHaveBeenCalledTimes(0);
});
it('applies labels when new count equals the limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('limit_2.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b']
});
});
it('skips all changed-files labels when limit is 0', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'test/some-feature'};
usingLabelerConfigYaml('limit_0.yml');
mockGitHubResponseChangedFiles('components/a/file.ts');
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// With limit 0, only branch-based labels should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['test-branch']
});
});
it('treats labels with mixed rules as changed-files labels', async () => {
// A label that has both branch and changed-files rules is considered
// a "changed-files label" and subject to the limit, even if it matches
// via the branch rule
configureInput({});
github.context.payload.pull_request!.head = {ref: 'test/some-feature'};
usingLabelerConfigYaml('mixed_rules.yml');
mockGitHubResponseChangedFiles('unrelated/file.ts');
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// The mixed-label matches via branch rule but is still subject to limit
// because it contains a changed-files rule in its definition.
// Only pure-branch-label should be applied.
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['pure-branch-label']
});
});
});
describe('max-files-changed', () => {
it('applies labels when changed files count is within limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('max_files_5.yml');
mockGitHubResponseChangedFiles(
'components/a/file.ts',
'components/b/file.ts',
'components/c/file.ts'
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b', 'component-c']
});
});
it('skips file-based labels when changed files exceed limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('max_files_5.yml');
mockGitHubResponseChangedFiles(
'components/a/file1.ts',
'components/a/file2.ts',
'components/b/file1.ts',
'components/b/file2.ts',
'components/c/file1.ts',
'components/c/file2.ts' // 6 files > limit of 5
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// No labels should be applied since changed files exceed limit
expect(setLabelsMock).toHaveBeenCalledTimes(0);
});
it('applies labels when changed files count equals limit', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('max_files_5.yml');
mockGitHubResponseChangedFiles(
'components/a/file1.ts',
'components/a/file2.ts',
'components/b/file1.ts',
'components/b/file2.ts',
'components/c/file.ts' // exactly 5 files = limit
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b', 'component-c']
});
});
it('still applies branch-based labels when max-files-changed is exceeded', async () => {
configureInput({});
github.context.payload.pull_request!.head = {ref: 'test/some-feature'};
usingLabelerConfigYaml('max_files_with_branch.yml');
mockGitHubResponseChangedFiles(
'components/a/file1.ts',
'components/a/file2.ts',
'components/b/file1.ts',
'components/b/file2.ts' // 4 files > limit of 3
);
getPullMock.mockResolvedValue(<any>{
data: {labels: []}
});
await run();
// Only the branch-based label should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['test-branch']
});
});
it('preserves preexisting changed-files labels with sync-labels when max-files-changed is exceeded', async () => {
configureInput({'sync-labels': true});
github.context.payload.pull_request!.head = {ref: 'main'};
usingLabelerConfigYaml('max_files_5.yml');
mockGitHubResponseChangedFiles(
'unrelated/file1.ts',
'unrelated/file2.ts',
'unrelated/file3.ts',
'unrelated/file4.ts',
'unrelated/file5.ts',
'unrelated/file6.ts' // 6 files > limit of 5
);
getPullMock.mockResolvedValue(<any>{
data: {labels: [{name: 'component-a'}]} // preexisting label
});
await run();
// No setLabels call because labels should remain unchanged
// (component-a is preserved, not removed by sync-labels)
expect(setLabelsMock).toHaveBeenCalledTimes(0);
});
});
it('should use local configuration file if it exists', async () => {
const configFile = 'only_pdfs.yml';
const configFilePath = path.join(__dirname, 'fixtures', configFile);