Preserve externally managed labels during synchronization (#957)

* Preserve externally managed pull request labels

Replace whole-set label writes with batched selective additions and removals so sync-labels only manages configured labels.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182

* Reconcile ambiguous label addition failures

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182

* Handle paginated label reconciliation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182

---------

Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182
This commit is contained in:
Logan Rosen
2026-07-30 21:51:30 -05:00
committed by GitHub
co-authored by Copilot App
parent bf12e9b00b
commit b7a8804475
14 changed files with 553 additions and 2119 deletions
-58
View File
@@ -1,58 +0,0 @@
---
name: lodash.isequal
version: 4.5.0
type: npm
summary: The Lodash method `_.isEqual` exported as a module.
homepage: https://lodash.com/
license: mit
licenses:
- sources: LICENSE
text: |
Copyright JS Foundation and other contributors <https://js.foundation/>
Based on Underscore.js, copyright Jeremy Ashkenas,
DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
This software consists of voluntary contributions made by many
individuals. For exact contribution history, see the revision history
available at https://github.com/lodash/lodash
The following license applies to all parts of this software except as
documented below:
====
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
====
Copyright and related rights for sample code are waived via CC0. Sample
code is defined as all source code displayed within the prose of the
documentation.
CC0: http://creativecommons.org/publicdomain/zero/1.0/
====
Files located in the node_modules and vendor directories are externally
maintained libraries used by this software which have their own
licenses; we recommend you read them, as their terms may differ from the
terms above.
notices: []
+20 -1
View File
@@ -274,10 +274,24 @@ Various inputs are defined in [`action.yml`](action.yml) to let you configure th
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|
| `repo-token` | Token to use to authorize label changes. Typically the GITHUB_TOKEN secret | `github.token` |
| `configuration-path` | The path to the label configuration file. If the file doesn't exist at the specified path on the runner, action will read from the source repository via the Github API. | `.github/labeler.yml` |
| `sync-labels` | Whether or not to remove labels when matching files are reverted or no longer changed by the PR | `false` |
| `sync-labels` | Whether to remove configured labels when they no longer match. Labels not present in the labeler configuration are never removed. | `false` |
| `dot` | Whether or not to auto-include paths starting with dot (e.g. `.github`) | `true` |
| `pr-number` | The number(s) of pull request to update, rather than detecting from the workflow context | N/A |
When `sync-labels` is enabled, labeler synchronizes only labels whose names are
present in the labeler configuration. Matching configured labels are added in
one batch, and configured labels that no longer match are removed in one batch.
Other labels, including labels added by users or other automation, are not
rewritten or removed.
Removals and additions use separate API requests, so configured labels may be
temporarily absent while a synchronization swaps stale labels for newly matching
labels. Swaps can take longer than updates requiring only additions or removals
because these requests run sequentially. At GitHub's 100-label limit, labels
added concurrently between those requests can prevent all newly matching
configured labels from being added; labeler does not remove unconfigured labels
to make room.
##### Using `configuration-path` input together with the `@actions/checkout` action
You might want to use action called [@actions/checkout](https://github.com/actions/checkout) to upload label configuration file onto the runner from the current or any other repositories. See usage example below:
@@ -328,6 +342,11 @@ Labeler provides the following outputs:
| `new-labels` | A comma-separated list of all new labels |
| `all-labels` | A comma-separated list of all labels that the PR contains |
The outputs are calculated from the pull request label snapshot used by the
action and the label changes it successfully applies. A label added
concurrently by another actor is preserved, but may not appear in the outputs
for that run.
The following example performs steps based on the output of labeler:
```yml
name: "Pull Request Labeler"
+131
View File
@@ -0,0 +1,131 @@
import {jest, describe, it, expect} from '@jest/globals';
import type {ClientType} from '../src/api/types.js';
jest.unstable_mockModule('@actions/github', () => ({
context: {
repo: {owner: 'monalisa', repo: 'helloworld'}
}
}));
const {addLabels} = await import('../src/api/add-labels.js');
const createClient = () => {
const addLabelsMock = jest.fn<any>();
const listLabelsOnIssueMock = jest.fn<any>();
const client = {
rest: {
issues: {
addLabels: addLabelsMock,
listLabelsOnIssue: listLabelsOnIssueMock
}
}
} as ClientType;
return {client, addLabelsMock, listLabelsOnIssueMock};
};
describe('addLabels', () => {
it('does not verify a successful addition', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
addLabelsMock.mockResolvedValue({data: []});
await addLabels(client, 123, ['bug']);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['bug'],
request: {retries: 0}
});
expect(listLabelsOnIssueMock).not.toHaveBeenCalled();
});
it('accepts a server error when every requested label was committed', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const serverError = Object.assign(new Error('Bad Gateway'), {status: 502});
addLabelsMock.mockRejectedValue(serverError);
listLabelsOnIssueMock.mockResolvedValue({
data: [{name: 'BUG'}, {name: 'documentation'}],
headers: {}
});
await expect(
addLabels(client, 123, ['bug', 'documentation'])
).resolves.toBeUndefined();
expect(listLabelsOnIssueMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
per_page: 100,
page: 1,
request: {retries: 0}
});
});
it('checks subsequent pages after a committed server error', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const serverError = Object.assign(new Error('Bad Gateway'), {status: 502});
addLabelsMock.mockRejectedValue(serverError);
listLabelsOnIssueMock
.mockResolvedValueOnce({
data: Array.from({length: 100}, (_, index) => ({
name: `label-${index}`
})),
headers: {
link: '<https://api.github.com/issues/123/labels?page=2>; rel="next"'
}
})
.mockResolvedValueOnce({
data: [{name: 'documentation'}],
headers: {}
});
await expect(
addLabels(client, 123, ['label-0', 'documentation'])
).resolves.toBeUndefined();
expect(listLabelsOnIssueMock).toHaveBeenNthCalledWith(2, {
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
per_page: 100,
page: 2,
request: {retries: 0}
});
});
it('preserves a server error when any requested label is missing', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const serverError = Object.assign(new Error('Bad Gateway'), {status: 502});
addLabelsMock.mockRejectedValue(serverError);
listLabelsOnIssueMock.mockResolvedValue({
data: [{name: 'bug'}],
headers: {}
});
await expect(addLabels(client, 123, ['bug', 'documentation'])).rejects.toBe(
serverError
);
expect(listLabelsOnIssueMock).toHaveBeenCalledTimes(1);
});
it('does not verify non-server errors', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const validationError = Object.assign(new Error('Validation Failed'), {
status: 422
});
addLabelsMock.mockRejectedValue(validationError);
await expect(addLabels(client, 123, ['bug'])).rejects.toBe(validationError);
expect(listLabelsOnIssueMock).not.toHaveBeenCalled();
});
it('preserves the server error when verification fails', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const serverError = Object.assign(new Error('Bad Gateway'), {status: 502});
addLabelsMock.mockRejectedValue(serverError);
listLabelsOnIssueMock.mockRejectedValue(new Error('Service unavailable'));
await expect(addLabels(client, 123, ['bug'])).rejects.toBe(serverError);
});
});
+36 -5
View File
@@ -9,7 +9,8 @@ import type {
// Define API mock functions at module level
const getPullRequestsMock = jest.fn<any>();
const getLabelConfigsMock = jest.fn<any>();
const setLabelsMock = jest.fn<any>();
const addLabelsMock = jest.fn<any>();
const removeLabelsMock = jest.fn<any>();
const getChangedFilesMock = jest.fn<any>();
const getContentMock = jest.fn<any>();
@@ -42,7 +43,8 @@ jest.unstable_mockModule('@actions/github', () => ({
jest.unstable_mockModule('../src/api/index.js', () => ({
getPullRequests: getPullRequestsMock,
getLabelConfigs: getLabelConfigsMock,
setLabels: setLabelsMock,
addLabels: addLabelsMock,
removeLabels: removeLabelsMock,
getChangedFiles: getChangedFilesMock,
getContent: getContentMock
}));
@@ -478,7 +480,7 @@ describe('labeler error handling', () => {
});
it('throws a custom error for HttpError 403 with "unauthorized" message', async () => {
setLabelsMock.mockRejectedValue({
addLabelsMock.mockRejectedValue({
name: 'HttpError',
status: 403,
message: 'Request failed with status code 403: Unauthorized'
@@ -495,7 +497,7 @@ describe('labeler error handling', () => {
status: 404,
message: 'Not Found'
};
setLabelsMock.mockRejectedValue(unexpectedError);
addLabelsMock.mockRejectedValue(unexpectedError);
// NOTE: In the current implementation, labeler rethrows the raw error object (not an Error instance).
// `rejects.toThrow` only works with real Error objects, so here we must use `rejects.toEqual`.
@@ -508,7 +510,7 @@ describe('labeler error handling', () => {
name: 'HttpError',
message: 'Resource not accessible by integration'
};
setLabelsMock.mockRejectedValue(error);
addLabelsMock.mockRejectedValue(error);
await labeler();
@@ -518,4 +520,33 @@ describe('labeler error handling', () => {
);
expect(core.setFailed).toHaveBeenCalledWith(error.message);
});
it('reports the configured labels when a bulk removal fails', async () => {
(core.getBooleanInput as jest.Mock).mockReturnValue(true);
getPullRequestsMock.mockReturnValue([
{
number: 123,
data: {
node_id: 'PR_node_id',
labels: [{name: 'stale-label', node_id: 'label_node_id'}]
},
changedFiles: ['file.txt']
}
]);
getLabelConfigsMock.mockResolvedValue({
labelConfigs: new Map([
[
'stale-label',
[{any: [{changedFiles: [{anyGlobToAnyFile: ['*.pdf']}]}]}]
]
]),
changedFilesLimit: undefined
});
removeLabelsMock.mockRejectedValue(new Error('GraphQL request failed'));
await expect(labeler()).rejects.toThrow(
"Failed to remove configured labels 'stale-label' from PR #123"
);
expect(addLabelsMock).not.toHaveBeenCalled();
});
});
+148 -73
View File
@@ -14,6 +14,13 @@ import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Define mock functions before mocking modules
const addLabelsMock = jest.fn<any>();
const addLabelsRequestMock = jest.fn<any>(options => {
const params = {...options};
delete params.request;
return addLabelsMock(params);
});
const removeLabelsMock = jest.fn<any>();
const setLabelsMock = jest.fn<any>();
const reposMock = jest.fn<any>();
const paginateMock = jest.fn<any>();
@@ -54,8 +61,12 @@ jest.unstable_mockModule('@actions/core', () => ({
jest.unstable_mockModule('@actions/github', () => ({
context: mockGithubContext,
getOctokit: jest.fn(() => ({
graphql: removeLabelsMock,
rest: {
issues: {setLabels: setLabelsMock},
issues: {
addLabels: addLabelsRequestMock,
setLabels: setLabelsMock
},
repos: {getContent: reposMock},
pulls: {
get: getPullMock,
@@ -144,14 +155,17 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['touched-a-pdf-file']
});
expect(addLabelsRequestMock).toHaveBeenCalledWith(
expect.objectContaining({request: {retries: 0}})
);
expect(setOutputSpy).toHaveBeenCalledWith(
'new-labels',
'touched-a-pdf-file'
@@ -174,8 +188,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -191,6 +205,29 @@ describe('run', () => {
);
});
it('does not lose a label added concurrently with matching labels', async () => {
configureInput({'sync-labels': true});
usingLabelerConfigYaml('only_pdfs.yml');
mockGitHubResponseChangedFiles('foo.pdf');
const labelsOnPullRequest: string[] = [];
getPullMock.mockResolvedValue(<any>{
data: {node_id: 'PR_node_id', labels: []}
});
addLabelsMock.mockImplementationOnce(({labels}: {labels: string[]}) => {
labelsOnPullRequest.push('external-label', ...labels);
});
await run();
expect(labelsOnPullRequest).toEqual([
'external-label',
'touched-a-pdf-file'
]);
expect(getPullMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).not.toHaveBeenCalled();
});
it('(with dot: false) does not add labels to PRs that do not match our glob patterns', async () => {
configureInput({});
usingLabelerConfigYaml('only_pdfs.yml');
@@ -203,7 +240,7 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
expect(setOutputSpy).toHaveBeenCalledWith('new-labels', '');
expect(setOutputSpy).toHaveBeenCalledWith('all-labels', '');
});
@@ -215,7 +252,7 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('does not add a label when the match config options are not supported', async () => {
@@ -223,7 +260,7 @@ describe('run', () => {
usingLabelerConfigYaml('not_supported.yml');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('adds labels based on the branch names that match the regexp pattern', async () => {
@@ -232,8 +269,8 @@ describe('run', () => {
usingLabelerConfigYaml('branches.yml');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -252,8 +289,8 @@ describe('run', () => {
usingLabelerConfigYaml('branches.yml');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -276,8 +313,8 @@ describe('run', () => {
usingLabelerConfigYaml('branches.yml');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -294,8 +331,8 @@ describe('run', () => {
usingLabelerConfigYaml('branches.yml');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -312,8 +349,8 @@ describe('run', () => {
mockGitHubResponseChangedFiles('tests/test.ts');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -330,7 +367,7 @@ describe('run', () => {
mockGitHubResponseChangedFiles('tests/requirements.txt');
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('(with sync-labels: true) it deletes preexisting PR labels that no longer match the glob pattern', async () => {
@@ -344,19 +381,26 @@ describe('run', () => {
mockGitHubResponseChangedFiles('foo.txt');
getPullMock.mockResolvedValue(<any>{
data: {
labels: [{name: 'touched-a-pdf-file'}, {name: 'manually-added'}]
node_id: 'PR_node_id',
labels: [
{name: 'touched-a-pdf-file', node_id: 'stale_label_node_id'},
{name: 'manually-added', node_id: 'manual_label_node_id'}
]
}
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['manually-added']
});
expect(addLabelsMock).not.toHaveBeenCalled();
expect(removeLabelsMock).toHaveBeenCalledTimes(1);
expect(removeLabelsMock).toHaveBeenCalledWith(
expect.stringContaining('removeLabelsFromLabelable'),
{
labelableId: 'PR_node_id',
labelIds: ['stale_label_node_id']
}
);
expect(setLabelsMock).not.toHaveBeenCalled();
expect(setOutputSpy).toHaveBeenCalledWith('new-labels', '');
expect(setOutputSpy).toHaveBeenCalledWith('all-labels', 'manually-added');
@@ -379,7 +423,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
expect(removeLabelsMock).not.toHaveBeenCalled();
expect(setOutputSpy).toHaveBeenCalledWith('new-labels', '');
expect(setOutputSpy).toHaveBeenCalledWith(
'all-labels',
@@ -387,6 +432,36 @@ describe('run', () => {
);
});
it('removes stale configured labels in one mutation', async () => {
configureInput({'sync-labels': true});
usingLabelerConfigYaml('mixed_labels.yml');
mockGitHubResponseChangedFiles('unrelated.txt');
getPullMock.mockResolvedValue(<any>{
data: {
node_id: 'PR_node_id',
labels: [
{name: 'component-a', node_id: 'label_a'},
{name: 'component-b', node_id: 'label_b'},
{name: 'external-label', node_id: 'external_label'}
]
}
});
await run();
expect(removeLabelsMock).toHaveBeenCalledTimes(1);
expect(removeLabelsMock).toHaveBeenCalledWith(
expect.stringContaining('removeLabelsFromLabelable'),
{
labelableId: 'PR_node_id',
labelIds: ['label_a', 'label_b']
}
);
expect(addLabelsMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(setOutputSpy).toHaveBeenCalledWith('all-labels', 'external-label');
});
it('(with sync-labels: false) it only logs the excess labels', async () => {
configureInput({
'repo-token': 'foo',
@@ -408,7 +483,7 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
expect(coreWarningMock).toHaveBeenCalledTimes(1);
expect(coreWarningMock).toHaveBeenCalledWith(
@@ -437,12 +512,12 @@ describe('run', () => {
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 104,
labels: ['manually-added', 'touched-a-pdf-file']
labels: ['touched-a-pdf-file']
});
expect(setOutputSpy).toHaveBeenCalledWith(
'new-labels',
@@ -477,14 +552,14 @@ describe('run', () => {
});
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(2);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(2);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 104,
labels: ['manually-added', 'touched-a-pdf-file']
labels: ['touched-a-pdf-file']
});
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 150,
@@ -515,7 +590,7 @@ describe('run', () => {
"'abc' is not a valid pull request number"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: negative number) warns and makes no API call', async () => {
@@ -533,7 +608,7 @@ describe('run', () => {
"'-1' is not a valid pull request number"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: zero) warns and makes no API call', async () => {
@@ -551,7 +626,7 @@ describe('run', () => {
"'0' is not a valid pull request number"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: number with internal space) warns and makes no API call', async () => {
@@ -569,7 +644,7 @@ describe('run', () => {
"'10 4' is not a valid pull request number"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: number with trailing non-numeric chars) warns and makes no API call', async () => {
@@ -587,7 +662,7 @@ describe('run', () => {
"'104abc' is not a valid pull request number"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: valid number with surrounding whitespace) trims and processes correctly', async () => {
@@ -624,7 +699,7 @@ describe('run', () => {
"'abc\\x0ddef' is not a valid pull request number (non-printable characters were escaped as \\xNN)"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: string with tab) sanitizes tab as \\x09 in warning', async () => {
@@ -642,7 +717,7 @@ describe('run', () => {
"'abc\\x09def' is not a valid pull request number (non-printable characters were escaped as \\xNN)"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: string with ANSI escape sequence) sanitizes ESC byte as \\x1b in warning', async () => {
@@ -660,7 +735,7 @@ describe('run', () => {
"'abc\\x1b[31mINJECTED\\x1b[0m' is not a valid pull request number (non-printable characters were escaped as \\xNN)"
);
expect(getPullMock).not.toHaveBeenCalled();
expect(setLabelsMock).not.toHaveBeenCalled();
expect(addLabelsMock).not.toHaveBeenCalled();
});
it('(with pr-number: mix of valid and invalid) processes valid, skips invalid with warning', async () => {
@@ -682,8 +757,8 @@ describe('run', () => {
expect(coreWarningMock).toHaveBeenCalledWith(
"'abc' is not a valid pull request number"
);
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 104,
@@ -697,7 +772,7 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
describe('changed-files-labels-limit', () => {
@@ -715,8 +790,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -740,7 +815,7 @@ describe('run', () => {
await run();
// No labels should be applied since changed-files labels exceed limit
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('still applies branch-based labels when changed-files limit is exceeded', async () => {
@@ -759,8 +834,8 @@ describe('run', () => {
await run();
// Only the branch-based label should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -784,8 +859,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -811,12 +886,12 @@ describe('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({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['component-a', 'component-b', 'component-c', 'component-d']
labels: ['component-c', 'component-d']
});
});
@@ -838,7 +913,7 @@ describe('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);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('applies labels when new count equals the limit', async () => {
@@ -855,8 +930,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -876,8 +951,8 @@ describe('run', () => {
await run();
// With limit 0, only branch-based labels should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -902,8 +977,8 @@ describe('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({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -928,8 +1003,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -956,7 +1031,7 @@ describe('run', () => {
await run();
// No labels should be applied since changed files exceed limit
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
it('applies labels when changed files count equals limit', async () => {
@@ -976,8 +1051,8 @@ describe('run', () => {
await run();
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -1002,8 +1077,8 @@ describe('run', () => {
await run();
// Only the branch-based label should be applied
expect(setLabelsMock).toHaveBeenCalledTimes(1);
expect(setLabelsMock).toHaveBeenCalledWith({
expect(addLabelsMock).toHaveBeenCalledTimes(1);
expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
@@ -1029,9 +1104,9 @@ describe('run', () => {
await run();
// No setLabels call because labels should remain unchanged
// No mutation because labels should remain unchanged
// (component-a is preserved, not removed by sync-labels)
expect(setLabelsMock).toHaveBeenCalledTimes(0);
expect(addLabelsMock).toHaveBeenCalledTimes(0);
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ inputs:
default: '.github/labeler.yml'
required: false
sync-labels:
description: 'Whether or not to remove labels when matching files are reverted'
description: 'Whether to remove configured labels when they no longer match'
default: false
required: false
dot:
+94 -1909
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -13,13 +13,11 @@
"@actions/github": "^9.1.1",
"@octokit/plugin-retry": "^8.1.0",
"js-yaml": "^5.1.0",
"lodash.isequal": "^4.5.0",
"minimatch": "^10.2.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@jest/globals": "^30.4.1",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^24.13.2",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
@@ -1668,21 +1666,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.14.200",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz",
"integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==",
"dev": true
},
"node_modules/@types/lodash.isequal": {
"version": "4.5.8",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz",
"integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==",
"dev": true,
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -4700,11 +4683,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-2
View File
@@ -32,13 +32,11 @@
"@actions/github": "^9.1.1",
"@octokit/plugin-retry": "^8.1.0",
"js-yaml": "^5.1.0",
"lodash.isequal": "^4.5.0",
"minimatch": "^10.2.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@jest/globals": "^30.4.1",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^24.13.2",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
+65
View File
@@ -0,0 +1,65 @@
import * as github from '@actions/github';
import {ClientType} from './types.js';
const isServerError = (error: unknown): error is {status: number} =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number' &&
error.status >= 500 &&
error.status < 600;
export const addLabels = async (
client: ClientType,
prNumber: number,
labels: string[]
) => {
const request = {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: prNumber
};
try {
await client.rest.issues.addLabels({
...request,
labels,
request: {retries: 0}
});
} catch (error: unknown) {
if (!isServerError(error)) {
throw error;
}
const currentLabelNames = new Set<string>();
let page = 1;
try {
while (true) {
const currentLabels = await client.rest.issues.listLabelsOnIssue({
...request,
per_page: 100,
page,
request: {retries: 0}
});
for (const label of currentLabels.data) {
currentLabelNames.add(label.name.toLowerCase());
}
if (labels.every(label => currentLabelNames.has(label.toLowerCase()))) {
return;
}
if (!currentLabels.headers.link?.match(/;\s*rel="next"/)) {
break;
}
page++;
}
} catch {
throw error;
}
throw error;
}
};
+2 -1
View File
@@ -1,6 +1,7 @@
export * from './add-labels.js';
export * from './get-changed-files.js';
export * from './get-changed-pull-requests.js';
export * from './get-content.js';
export * from './get-label-configs.js';
export * from './set-labels.js';
export * from './remove-labels.js';
export * from './types.js';
+19
View File
@@ -0,0 +1,19 @@
import {ClientType} from './types.js';
const REMOVE_LABELS_MUTATION = `
mutation RemoveLabels($labelableId: ID!, $labelIds: [ID!]!) {
removeLabelsFromLabelable(
input: {labelableId: $labelableId, labelIds: $labelIds}
) {
clientMutationId
}
}
`;
export const removeLabels = async (
client: ClientType,
labelableId: string,
labelIds: string[]
) => {
await client.graphql(REMOVE_LABELS_MUTATION, {labelableId, labelIds});
};
-15
View File
@@ -1,15 +0,0 @@
import * as github from '@actions/github';
import {ClientType} from './types.js';
export const setLabels = async (
client: ClientType,
prNumber: number,
labels: string[]
) => {
await client.rest.issues.setLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: prNumber,
labels: labels
});
};
+38 -33
View File
@@ -2,7 +2,6 @@ import * as core from '@actions/core';
import * as github from '@actions/github';
import * as pluginRetry from '@octokit/plugin-retry';
import * as api from './api/index.js';
import isEqual from 'lodash.isequal';
import {getInputs} from './get-inputs/index.js';
import {
@@ -104,50 +103,56 @@ export async function labeler() {
const labelsToApply = [...allLabels].slice(0, GITHUB_MAX_LABELS);
const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS);
let finalLabels = labelsToApply;
let newLabels: string[] = [];
try {
if (!isEqual(labelsToApply, preexistingLabels)) {
// Fetch the latest labels for the PR
const latestLabels: string[] = [];
// Skip fetching real labels when running tests (uses mock data instead)
if (process.env.NODE_ENV !== 'test') {
const pr = await client.rest.pulls.get({
...github.context.repo,
pull_number: pullRequest.number
});
latestLabels.push(...pr.data.labels.map(l => l.name).filter(Boolean));
}
// Labels added manually during the run (not in first snapshot)
const manualAddedDuringRun = latestLabels.filter(
l => !preexistingLabels.includes(l)
const finalLabels = labelsToApply;
const newLabels = labelsToApply.filter(
label => !preexistingLabels.includes(label)
);
const staleLabels = pullRequest.data.labels.filter(
label => labelConfigs.has(label.name) && !allLabels.has(label.name)
);
// Preserve manual labels first, then apply config-based labels, respecting GitHub's 100-label limit
finalLabels = [
...new Set([...manualAddedDuringRun, ...labelsToApply])
].slice(0, GITHUB_MAX_LABELS);
try {
if (staleLabels.length) {
const labelableId = pullRequest.data.node_id;
const missingNodeId = staleLabels.find(label => !label.node_id);
if (!labelableId || missingNodeId) {
throw new Error(
`Failed to resolve node IDs while removing configured labels from PR #${pullRequest.number}`
);
}
await api.setLabels(client, pullRequest.number, finalLabels);
try {
await api.removeLabels(
client,
labelableId,
staleLabels.map(label => label.node_id)
);
} catch (error: any) {
throw new Error(
`Failed to remove configured labels '${staleLabels.map(label => label.name).join("', '")}' from PR #${pullRequest.number}`,
{cause: error}
);
}
}
newLabels = finalLabels.filter(l => !preexistingLabels.includes(l));
if (newLabels.length) {
await api.addLabels(client, pullRequest.number, newLabels);
}
} catch (error: any) {
const apiError = error.cause ?? error;
if (
error.name === 'HttpError' &&
error.status === 403 &&
error.message.toLowerCase().includes('unauthorized')
apiError.name === 'HttpError' &&
apiError.status === 403 &&
apiError.message.toLowerCase().includes('unauthorized')
) {
throw new Error(
`Failed to set labels for PR #${pullRequest.number}. The workflow does not have permission to create labels. ` +
`Failed to update labels for PR #${pullRequest.number}. The workflow does not have permission to create labels. ` +
`Ensure the 'issues: write' permission is granted in the workflow file or manually create the missing labels in the repository before running the action.`,
{cause: error}
);
} else if (
error.name !== 'HttpError' ||
error.message !== 'Resource not accessible by integration'
apiError.name !== 'HttpError' ||
apiError.message !== 'Resource not accessible by integration'
) {
throw error;
}
@@ -160,7 +165,7 @@ export async function labeler() {
}
);
core.setFailed(error.message);
core.setFailed(apiError.message);
return;
}