mirror of
https://github.com/actions/labeler
synced 2026-09-04 11:05:09 +02:00
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:
co-authored by
Copilot App
parent
bf12e9b00b
commit
b7a8804475
Generated
-58
@@ -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: []
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Vendored
+94
-1909
File diff suppressed because it is too large
Load Diff
Generated
-22
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user