From 86ce6f44686ca3c58dbc4c6ff62c9b982f36fa2e Mon Sep 17 00:00:00 2001 From: Logan Rosen Date: Mon, 20 Jul 2026 12:54:33 -0400 Subject: [PATCH 1/2] 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 --- .licenses/npm/lodash.isequal.dep.yml | 58 - README.md | 13 +- __tests__/labeler.test.ts | 41 +- __tests__/main.test.ts | 221 ++- action.yml | 2 +- dist/index.js | 1961 +--------------------- package-lock.json | 22 - package.json | 2 - src/api/{set-labels.ts => add-labels.ts} | 7 +- src/api/index.ts | 3 +- src/api/remove-labels.ts | 19 + src/labeler.ts | 69 +- 12 files changed, 311 insertions(+), 2107 deletions(-) delete mode 100644 .licenses/npm/lodash.isequal.dep.yml rename src/api/{set-labels.ts => add-labels.ts} (70%) create mode 100644 src/api/remove-labels.ts diff --git a/.licenses/npm/lodash.isequal.dep.yml b/.licenses/npm/lodash.isequal.dep.yml deleted file mode 100644 index 4bc8358e0..000000000 --- a/.licenses/npm/lodash.isequal.dep.yml +++ /dev/null @@ -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 - - Based on Underscore.js, copyright Jeremy Ashkenas, - DocumentCloud and Investigative Reporters & Editors - - 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: [] diff --git a/README.md b/README.md index 57a829bcc..03c4e2a69 100644 --- a/README.md +++ b/README.md @@ -274,10 +274,16 @@ 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. + ##### 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 +334,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" diff --git a/__tests__/labeler.test.ts b/__tests__/labeler.test.ts index b0bbc157d..9f21969df 100644 --- a/__tests__/labeler.test.ts +++ b/__tests__/labeler.test.ts @@ -9,7 +9,8 @@ import type { // Define API mock functions at module level const getPullRequestsMock = jest.fn(); const getLabelConfigsMock = jest.fn(); -const setLabelsMock = jest.fn(); +const addLabelsMock = jest.fn(); +const removeLabelsMock = jest.fn(); const getChangedFilesMock = jest.fn(); const getContentMock = jest.fn(); @@ -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(); + }); }); diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 0ac908c86..00fd1f7bf 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -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(); +const addLabelsRequestMock = jest.fn(options => { + const params = {...options}; + delete params.request; + return addLabelsMock(params); +}); +const removeLabelsMock = jest.fn(); const setLabelsMock = jest.fn(); const reposMock = jest.fn(); const paginateMock = jest.fn(); @@ -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({ + 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({ 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({ + 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); }); }); diff --git a/action.yml b/action.yml index c71c4e7b7..a1ec955a3 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/dist/index.js b/dist/index.js index 4780f4d03..e0caa953f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2372,1862 +2372,6 @@ class DecodedURL extends URL { }))); -/***/ }), - -/***/ 9471: -/***/ ((module, exports, __nccwpck_require__) => { - -/* module decorator */ module = __nccwpck_require__.nmd(module); -/** - * Lodash (Custom Build) - * Build: `lodash modularize exports="npm" -o ./` - * Copyright JS Foundation and other contributors - * Released under MIT license - * Based on Underscore.js 1.8.3 - * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - */ - -/** Used as the size to enable large array optimizations. */ -var LARGE_ARRAY_SIZE = 200; - -/** Used to stand-in for `undefined` hash values. */ -var HASH_UNDEFINED = '__lodash_hash_undefined__'; - -/** Used to compose bitmasks for value comparisons. */ -var COMPARE_PARTIAL_FLAG = 1, - COMPARE_UNORDERED_FLAG = 2; - -/** Used as references for various `Number` constants. */ -var MAX_SAFE_INTEGER = 9007199254740991; - -/** `Object#toString` result references. */ -var argsTag = '[object Arguments]', - arrayTag = '[object Array]', - asyncTag = '[object AsyncFunction]', - boolTag = '[object Boolean]', - dateTag = '[object Date]', - errorTag = '[object Error]', - funcTag = '[object Function]', - genTag = '[object GeneratorFunction]', - mapTag = '[object Map]', - numberTag = '[object Number]', - nullTag = '[object Null]', - objectTag = '[object Object]', - promiseTag = '[object Promise]', - proxyTag = '[object Proxy]', - regexpTag = '[object RegExp]', - setTag = '[object Set]', - stringTag = '[object String]', - symbolTag = '[object Symbol]', - undefinedTag = '[object Undefined]', - weakMapTag = '[object WeakMap]'; - -var arrayBufferTag = '[object ArrayBuffer]', - dataViewTag = '[object DataView]', - float32Tag = '[object Float32Array]', - float64Tag = '[object Float64Array]', - int8Tag = '[object Int8Array]', - int16Tag = '[object Int16Array]', - int32Tag = '[object Int32Array]', - uint8Tag = '[object Uint8Array]', - uint8ClampedTag = '[object Uint8ClampedArray]', - uint16Tag = '[object Uint16Array]', - uint32Tag = '[object Uint32Array]'; - -/** - * Used to match `RegExp` - * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns). - */ -var reRegExpChar = /[\\^$.*+?()[\]{}|]/g; - -/** Used to detect host constructors (Safari). */ -var reIsHostCtor = /^\[object .+?Constructor\]$/; - -/** Used to detect unsigned integer values. */ -var reIsUint = /^(?:0|[1-9]\d*)$/; - -/** Used to identify `toStringTag` values of typed arrays. */ -var typedArrayTags = {}; -typedArrayTags[float32Tag] = typedArrayTags[float64Tag] = -typedArrayTags[int8Tag] = typedArrayTags[int16Tag] = -typedArrayTags[int32Tag] = typedArrayTags[uint8Tag] = -typedArrayTags[uint8ClampedTag] = typedArrayTags[uint16Tag] = -typedArrayTags[uint32Tag] = true; -typedArrayTags[argsTag] = typedArrayTags[arrayTag] = -typedArrayTags[arrayBufferTag] = typedArrayTags[boolTag] = -typedArrayTags[dataViewTag] = typedArrayTags[dateTag] = -typedArrayTags[errorTag] = typedArrayTags[funcTag] = -typedArrayTags[mapTag] = typedArrayTags[numberTag] = -typedArrayTags[objectTag] = typedArrayTags[regexpTag] = -typedArrayTags[setTag] = typedArrayTags[stringTag] = -typedArrayTags[weakMapTag] = false; - -/** Detect free variable `global` from Node.js. */ -var freeGlobal = typeof global == 'object' && global && global.Object === Object && global; - -/** Detect free variable `self`. */ -var freeSelf = typeof self == 'object' && self && self.Object === Object && self; - -/** Used as a reference to the global object. */ -var root = freeGlobal || freeSelf || Function('return this')(); - -/** Detect free variable `exports`. */ -var freeExports = true && exports && !exports.nodeType && exports; - -/** Detect free variable `module`. */ -var freeModule = freeExports && "object" == 'object' && module && !module.nodeType && module; - -/** Detect the popular CommonJS extension `module.exports`. */ -var moduleExports = freeModule && freeModule.exports === freeExports; - -/** Detect free variable `process` from Node.js. */ -var freeProcess = moduleExports && freeGlobal.process; - -/** Used to access faster Node.js helpers. */ -var nodeUtil = (function() { - try { - return freeProcess && freeProcess.binding && freeProcess.binding('util'); - } catch (e) {} -}()); - -/* Node.js helper references. */ -var nodeIsTypedArray = nodeUtil && nodeUtil.isTypedArray; - -/** - * A specialized version of `_.filter` for arrays without support for - * iteratee shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {Array} Returns the new filtered array. - */ -function arrayFilter(array, predicate) { - var index = -1, - length = array == null ? 0 : array.length, - resIndex = 0, - result = []; - - while (++index < length) { - var value = array[index]; - if (predicate(value, index, array)) { - result[resIndex++] = value; - } - } - return result; -} - -/** - * Appends the elements of `values` to `array`. - * - * @private - * @param {Array} array The array to modify. - * @param {Array} values The values to append. - * @returns {Array} Returns `array`. - */ -function arrayPush(array, values) { - var index = -1, - length = values.length, - offset = array.length; - - while (++index < length) { - array[offset + index] = values[index]; - } - return array; -} - -/** - * A specialized version of `_.some` for arrays without support for iteratee - * shorthands. - * - * @private - * @param {Array} [array] The array to iterate over. - * @param {Function} predicate The function invoked per iteration. - * @returns {boolean} Returns `true` if any element passes the predicate check, - * else `false`. - */ -function arraySome(array, predicate) { - var index = -1, - length = array == null ? 0 : array.length; - - while (++index < length) { - if (predicate(array[index], index, array)) { - return true; - } - } - return false; -} - -/** - * The base implementation of `_.times` without support for iteratee shorthands - * or max array length checks. - * - * @private - * @param {number} n The number of times to invoke `iteratee`. - * @param {Function} iteratee The function invoked per iteration. - * @returns {Array} Returns the array of results. - */ -function baseTimes(n, iteratee) { - var index = -1, - result = Array(n); - - while (++index < n) { - result[index] = iteratee(index); - } - return result; -} - -/** - * The base implementation of `_.unary` without support for storing metadata. - * - * @private - * @param {Function} func The function to cap arguments for. - * @returns {Function} Returns the new capped function. - */ -function baseUnary(func) { - return function(value) { - return func(value); - }; -} - -/** - * Checks if a `cache` value for `key` exists. - * - * @private - * @param {Object} cache The cache to query. - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ -function cacheHas(cache, key) { - return cache.has(key); -} - -/** - * Gets the value at `key` of `object`. - * - * @private - * @param {Object} [object] The object to query. - * @param {string} key The key of the property to get. - * @returns {*} Returns the property value. - */ -function getValue(object, key) { - return object == null ? undefined : object[key]; -} - -/** - * Converts `map` to its key-value pairs. - * - * @private - * @param {Object} map The map to convert. - * @returns {Array} Returns the key-value pairs. - */ -function mapToArray(map) { - var index = -1, - result = Array(map.size); - - map.forEach(function(value, key) { - result[++index] = [key, value]; - }); - return result; -} - -/** - * Creates a unary function that invokes `func` with its argument transformed. - * - * @private - * @param {Function} func The function to wrap. - * @param {Function} transform The argument transform. - * @returns {Function} Returns the new function. - */ -function overArg(func, transform) { - return function(arg) { - return func(transform(arg)); - }; -} - -/** - * Converts `set` to an array of its values. - * - * @private - * @param {Object} set The set to convert. - * @returns {Array} Returns the values. - */ -function setToArray(set) { - var index = -1, - result = Array(set.size); - - set.forEach(function(value) { - result[++index] = value; - }); - return result; -} - -/** Used for built-in method references. */ -var arrayProto = Array.prototype, - funcProto = Function.prototype, - objectProto = Object.prototype; - -/** Used to detect overreaching core-js shims. */ -var coreJsData = root['__core-js_shared__']; - -/** Used to resolve the decompiled source of functions. */ -var funcToString = funcProto.toString; - -/** Used to check objects for own properties. */ -var hasOwnProperty = objectProto.hasOwnProperty; - -/** Used to detect methods masquerading as native. */ -var maskSrcKey = (function() { - var uid = /[^.]+$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || ''); - return uid ? ('Symbol(src)_1.' + uid) : ''; -}()); - -/** - * Used to resolve the - * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) - * of values. - */ -var nativeObjectToString = objectProto.toString; - -/** Used to detect if a method is native. */ -var reIsNative = RegExp('^' + - funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\$&') - .replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?') + '$' -); - -/** Built-in value references. */ -var Buffer = moduleExports ? root.Buffer : undefined, - Symbol = root.Symbol, - Uint8Array = root.Uint8Array, - propertyIsEnumerable = objectProto.propertyIsEnumerable, - splice = arrayProto.splice, - symToStringTag = Symbol ? Symbol.toStringTag : undefined; - -/* Built-in method references for those with the same name as other `lodash` methods. */ -var nativeGetSymbols = Object.getOwnPropertySymbols, - nativeIsBuffer = Buffer ? Buffer.isBuffer : undefined, - nativeKeys = overArg(Object.keys, Object); - -/* Built-in method references that are verified to be native. */ -var DataView = getNative(root, 'DataView'), - Map = getNative(root, 'Map'), - Promise = getNative(root, 'Promise'), - Set = getNative(root, 'Set'), - WeakMap = getNative(root, 'WeakMap'), - nativeCreate = getNative(Object, 'create'); - -/** Used to detect maps, sets, and weakmaps. */ -var dataViewCtorString = toSource(DataView), - mapCtorString = toSource(Map), - promiseCtorString = toSource(Promise), - setCtorString = toSource(Set), - weakMapCtorString = toSource(WeakMap); - -/** Used to convert symbols to primitives and strings. */ -var symbolProto = Symbol ? Symbol.prototype : undefined, - symbolValueOf = symbolProto ? symbolProto.valueOf : undefined; - -/** - * Creates a hash object. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ -function Hash(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } -} - -/** - * Removes all key-value entries from the hash. - * - * @private - * @name clear - * @memberOf Hash - */ -function hashClear() { - this.__data__ = nativeCreate ? nativeCreate(null) : {}; - this.size = 0; -} - -/** - * Removes `key` and its value from the hash. - * - * @private - * @name delete - * @memberOf Hash - * @param {Object} hash The hash to modify. - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ -function hashDelete(key) { - var result = this.has(key) && delete this.__data__[key]; - this.size -= result ? 1 : 0; - return result; -} - -/** - * Gets the hash value for `key`. - * - * @private - * @name get - * @memberOf Hash - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ -function hashGet(key) { - var data = this.__data__; - if (nativeCreate) { - var result = data[key]; - return result === HASH_UNDEFINED ? undefined : result; - } - return hasOwnProperty.call(data, key) ? data[key] : undefined; -} - -/** - * Checks if a hash value for `key` exists. - * - * @private - * @name has - * @memberOf Hash - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ -function hashHas(key) { - var data = this.__data__; - return nativeCreate ? (data[key] !== undefined) : hasOwnProperty.call(data, key); -} - -/** - * Sets the hash `key` to `value`. - * - * @private - * @name set - * @memberOf Hash - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the hash instance. - */ -function hashSet(key, value) { - var data = this.__data__; - this.size += this.has(key) ? 0 : 1; - data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value; - return this; -} - -// Add methods to `Hash`. -Hash.prototype.clear = hashClear; -Hash.prototype['delete'] = hashDelete; -Hash.prototype.get = hashGet; -Hash.prototype.has = hashHas; -Hash.prototype.set = hashSet; - -/** - * Creates an list cache object. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ -function ListCache(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } -} - -/** - * Removes all key-value entries from the list cache. - * - * @private - * @name clear - * @memberOf ListCache - */ -function listCacheClear() { - this.__data__ = []; - this.size = 0; -} - -/** - * Removes `key` and its value from the list cache. - * - * @private - * @name delete - * @memberOf ListCache - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ -function listCacheDelete(key) { - var data = this.__data__, - index = assocIndexOf(data, key); - - if (index < 0) { - return false; - } - var lastIndex = data.length - 1; - if (index == lastIndex) { - data.pop(); - } else { - splice.call(data, index, 1); - } - --this.size; - return true; -} - -/** - * Gets the list cache value for `key`. - * - * @private - * @name get - * @memberOf ListCache - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ -function listCacheGet(key) { - var data = this.__data__, - index = assocIndexOf(data, key); - - return index < 0 ? undefined : data[index][1]; -} - -/** - * Checks if a list cache value for `key` exists. - * - * @private - * @name has - * @memberOf ListCache - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ -function listCacheHas(key) { - return assocIndexOf(this.__data__, key) > -1; -} - -/** - * Sets the list cache `key` to `value`. - * - * @private - * @name set - * @memberOf ListCache - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the list cache instance. - */ -function listCacheSet(key, value) { - var data = this.__data__, - index = assocIndexOf(data, key); - - if (index < 0) { - ++this.size; - data.push([key, value]); - } else { - data[index][1] = value; - } - return this; -} - -// Add methods to `ListCache`. -ListCache.prototype.clear = listCacheClear; -ListCache.prototype['delete'] = listCacheDelete; -ListCache.prototype.get = listCacheGet; -ListCache.prototype.has = listCacheHas; -ListCache.prototype.set = listCacheSet; - -/** - * Creates a map cache object to store key-value pairs. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ -function MapCache(entries) { - var index = -1, - length = entries == null ? 0 : entries.length; - - this.clear(); - while (++index < length) { - var entry = entries[index]; - this.set(entry[0], entry[1]); - } -} - -/** - * Removes all key-value entries from the map. - * - * @private - * @name clear - * @memberOf MapCache - */ -function mapCacheClear() { - this.size = 0; - this.__data__ = { - 'hash': new Hash, - 'map': new (Map || ListCache), - 'string': new Hash - }; -} - -/** - * Removes `key` and its value from the map. - * - * @private - * @name delete - * @memberOf MapCache - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ -function mapCacheDelete(key) { - var result = getMapData(this, key)['delete'](key); - this.size -= result ? 1 : 0; - return result; -} - -/** - * Gets the map value for `key`. - * - * @private - * @name get - * @memberOf MapCache - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ -function mapCacheGet(key) { - return getMapData(this, key).get(key); -} - -/** - * Checks if a map value for `key` exists. - * - * @private - * @name has - * @memberOf MapCache - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ -function mapCacheHas(key) { - return getMapData(this, key).has(key); -} - -/** - * Sets the map `key` to `value`. - * - * @private - * @name set - * @memberOf MapCache - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the map cache instance. - */ -function mapCacheSet(key, value) { - var data = getMapData(this, key), - size = data.size; - - data.set(key, value); - this.size += data.size == size ? 0 : 1; - return this; -} - -// Add methods to `MapCache`. -MapCache.prototype.clear = mapCacheClear; -MapCache.prototype['delete'] = mapCacheDelete; -MapCache.prototype.get = mapCacheGet; -MapCache.prototype.has = mapCacheHas; -MapCache.prototype.set = mapCacheSet; - -/** - * - * Creates an array cache object to store unique values. - * - * @private - * @constructor - * @param {Array} [values] The values to cache. - */ -function SetCache(values) { - var index = -1, - length = values == null ? 0 : values.length; - - this.__data__ = new MapCache; - while (++index < length) { - this.add(values[index]); - } -} - -/** - * Adds `value` to the array cache. - * - * @private - * @name add - * @memberOf SetCache - * @alias push - * @param {*} value The value to cache. - * @returns {Object} Returns the cache instance. - */ -function setCacheAdd(value) { - this.__data__.set(value, HASH_UNDEFINED); - return this; -} - -/** - * Checks if `value` is in the array cache. - * - * @private - * @name has - * @memberOf SetCache - * @param {*} value The value to search for. - * @returns {number} Returns `true` if `value` is found, else `false`. - */ -function setCacheHas(value) { - return this.__data__.has(value); -} - -// Add methods to `SetCache`. -SetCache.prototype.add = SetCache.prototype.push = setCacheAdd; -SetCache.prototype.has = setCacheHas; - -/** - * Creates a stack cache object to store key-value pairs. - * - * @private - * @constructor - * @param {Array} [entries] The key-value pairs to cache. - */ -function Stack(entries) { - var data = this.__data__ = new ListCache(entries); - this.size = data.size; -} - -/** - * Removes all key-value entries from the stack. - * - * @private - * @name clear - * @memberOf Stack - */ -function stackClear() { - this.__data__ = new ListCache; - this.size = 0; -} - -/** - * Removes `key` and its value from the stack. - * - * @private - * @name delete - * @memberOf Stack - * @param {string} key The key of the value to remove. - * @returns {boolean} Returns `true` if the entry was removed, else `false`. - */ -function stackDelete(key) { - var data = this.__data__, - result = data['delete'](key); - - this.size = data.size; - return result; -} - -/** - * Gets the stack value for `key`. - * - * @private - * @name get - * @memberOf Stack - * @param {string} key The key of the value to get. - * @returns {*} Returns the entry value. - */ -function stackGet(key) { - return this.__data__.get(key); -} - -/** - * Checks if a stack value for `key` exists. - * - * @private - * @name has - * @memberOf Stack - * @param {string} key The key of the entry to check. - * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`. - */ -function stackHas(key) { - return this.__data__.has(key); -} - -/** - * Sets the stack `key` to `value`. - * - * @private - * @name set - * @memberOf Stack - * @param {string} key The key of the value to set. - * @param {*} value The value to set. - * @returns {Object} Returns the stack cache instance. - */ -function stackSet(key, value) { - var data = this.__data__; - if (data instanceof ListCache) { - var pairs = data.__data__; - if (!Map || (pairs.length < LARGE_ARRAY_SIZE - 1)) { - pairs.push([key, value]); - this.size = ++data.size; - return this; - } - data = this.__data__ = new MapCache(pairs); - } - data.set(key, value); - this.size = data.size; - return this; -} - -// Add methods to `Stack`. -Stack.prototype.clear = stackClear; -Stack.prototype['delete'] = stackDelete; -Stack.prototype.get = stackGet; -Stack.prototype.has = stackHas; -Stack.prototype.set = stackSet; - -/** - * Creates an array of the enumerable property names of the array-like `value`. - * - * @private - * @param {*} value The value to query. - * @param {boolean} inherited Specify returning inherited property names. - * @returns {Array} Returns the array of property names. - */ -function arrayLikeKeys(value, inherited) { - var isArr = isArray(value), - isArg = !isArr && isArguments(value), - isBuff = !isArr && !isArg && isBuffer(value), - isType = !isArr && !isArg && !isBuff && isTypedArray(value), - skipIndexes = isArr || isArg || isBuff || isType, - result = skipIndexes ? baseTimes(value.length, String) : [], - length = result.length; - - for (var key in value) { - if ((inherited || hasOwnProperty.call(value, key)) && - !(skipIndexes && ( - // Safari 9 has enumerable `arguments.length` in strict mode. - key == 'length' || - // Node.js 0.10 has enumerable non-index properties on buffers. - (isBuff && (key == 'offset' || key == 'parent')) || - // PhantomJS 2 has enumerable non-index properties on typed arrays. - (isType && (key == 'buffer' || key == 'byteLength' || key == 'byteOffset')) || - // Skip index properties. - isIndex(key, length) - ))) { - result.push(key); - } - } - return result; -} - -/** - * Gets the index at which the `key` is found in `array` of key-value pairs. - * - * @private - * @param {Array} array The array to inspect. - * @param {*} key The key to search for. - * @returns {number} Returns the index of the matched value, else `-1`. - */ -function assocIndexOf(array, key) { - var length = array.length; - while (length--) { - if (eq(array[length][0], key)) { - return length; - } - } - return -1; -} - -/** - * The base implementation of `getAllKeys` and `getAllKeysIn` which uses - * `keysFunc` and `symbolsFunc` to get the enumerable property names and - * symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @param {Function} keysFunc The function to get the keys of `object`. - * @param {Function} symbolsFunc The function to get the symbols of `object`. - * @returns {Array} Returns the array of property names and symbols. - */ -function baseGetAllKeys(object, keysFunc, symbolsFunc) { - var result = keysFunc(object); - return isArray(object) ? result : arrayPush(result, symbolsFunc(object)); -} - -/** - * The base implementation of `getTag` without fallbacks for buggy environments. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the `toStringTag`. - */ -function baseGetTag(value) { - if (value == null) { - return value === undefined ? undefinedTag : nullTag; - } - return (symToStringTag && symToStringTag in Object(value)) - ? getRawTag(value) - : objectToString(value); -} - -/** - * The base implementation of `_.isArguments`. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an `arguments` object, - */ -function baseIsArguments(value) { - return isObjectLike(value) && baseGetTag(value) == argsTag; -} - -/** - * The base implementation of `_.isEqual` which supports partial comparisons - * and tracks traversed objects. - * - * @private - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @param {boolean} bitmask The bitmask flags. - * 1 - Unordered comparison - * 2 - Partial comparison - * @param {Function} [customizer] The function to customize comparisons. - * @param {Object} [stack] Tracks traversed `value` and `other` objects. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - */ -function baseIsEqual(value, other, bitmask, customizer, stack) { - if (value === other) { - return true; - } - if (value == null || other == null || (!isObjectLike(value) && !isObjectLike(other))) { - return value !== value && other !== other; - } - return baseIsEqualDeep(value, other, bitmask, customizer, baseIsEqual, stack); -} - -/** - * A specialized version of `baseIsEqual` for arrays and objects which performs - * deep comparisons and tracks traversed objects enabling objects with circular - * references to be compared. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} [stack] Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ -function baseIsEqualDeep(object, other, bitmask, customizer, equalFunc, stack) { - var objIsArr = isArray(object), - othIsArr = isArray(other), - objTag = objIsArr ? arrayTag : getTag(object), - othTag = othIsArr ? arrayTag : getTag(other); - - objTag = objTag == argsTag ? objectTag : objTag; - othTag = othTag == argsTag ? objectTag : othTag; - - var objIsObj = objTag == objectTag, - othIsObj = othTag == objectTag, - isSameTag = objTag == othTag; - - if (isSameTag && isBuffer(object)) { - if (!isBuffer(other)) { - return false; - } - objIsArr = true; - objIsObj = false; - } - if (isSameTag && !objIsObj) { - stack || (stack = new Stack); - return (objIsArr || isTypedArray(object)) - ? equalArrays(object, other, bitmask, customizer, equalFunc, stack) - : equalByTag(object, other, objTag, bitmask, customizer, equalFunc, stack); - } - if (!(bitmask & COMPARE_PARTIAL_FLAG)) { - var objIsWrapped = objIsObj && hasOwnProperty.call(object, '__wrapped__'), - othIsWrapped = othIsObj && hasOwnProperty.call(other, '__wrapped__'); - - if (objIsWrapped || othIsWrapped) { - var objUnwrapped = objIsWrapped ? object.value() : object, - othUnwrapped = othIsWrapped ? other.value() : other; - - stack || (stack = new Stack); - return equalFunc(objUnwrapped, othUnwrapped, bitmask, customizer, stack); - } - } - if (!isSameTag) { - return false; - } - stack || (stack = new Stack); - return equalObjects(object, other, bitmask, customizer, equalFunc, stack); -} - -/** - * The base implementation of `_.isNative` without bad shim checks. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a native function, - * else `false`. - */ -function baseIsNative(value) { - if (!isObject(value) || isMasked(value)) { - return false; - } - var pattern = isFunction(value) ? reIsNative : reIsHostCtor; - return pattern.test(toSource(value)); -} - -/** - * The base implementation of `_.isTypedArray` without Node.js optimizations. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. - */ -function baseIsTypedArray(value) { - return isObjectLike(value) && - isLength(value.length) && !!typedArrayTags[baseGetTag(value)]; -} - -/** - * The base implementation of `_.keys` which doesn't treat sparse arrays as dense. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - */ -function baseKeys(object) { - if (!isPrototype(object)) { - return nativeKeys(object); - } - var result = []; - for (var key in Object(object)) { - if (hasOwnProperty.call(object, key) && key != 'constructor') { - result.push(key); - } - } - return result; -} - -/** - * A specialized version of `baseIsEqualDeep` for arrays with support for - * partial deep comparisons. - * - * @private - * @param {Array} array The array to compare. - * @param {Array} other The other array to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `array` and `other` objects. - * @returns {boolean} Returns `true` if the arrays are equivalent, else `false`. - */ -function equalArrays(array, other, bitmask, customizer, equalFunc, stack) { - var isPartial = bitmask & COMPARE_PARTIAL_FLAG, - arrLength = array.length, - othLength = other.length; - - if (arrLength != othLength && !(isPartial && othLength > arrLength)) { - return false; - } - // Assume cyclic values are equal. - var stacked = stack.get(array); - if (stacked && stack.get(other)) { - return stacked == other; - } - var index = -1, - result = true, - seen = (bitmask & COMPARE_UNORDERED_FLAG) ? new SetCache : undefined; - - stack.set(array, other); - stack.set(other, array); - - // Ignore non-index properties. - while (++index < arrLength) { - var arrValue = array[index], - othValue = other[index]; - - if (customizer) { - var compared = isPartial - ? customizer(othValue, arrValue, index, other, array, stack) - : customizer(arrValue, othValue, index, array, other, stack); - } - if (compared !== undefined) { - if (compared) { - continue; - } - result = false; - break; - } - // Recursively compare arrays (susceptible to call stack limits). - if (seen) { - if (!arraySome(other, function(othValue, othIndex) { - if (!cacheHas(seen, othIndex) && - (arrValue === othValue || equalFunc(arrValue, othValue, bitmask, customizer, stack))) { - return seen.push(othIndex); - } - })) { - result = false; - break; - } - } else if (!( - arrValue === othValue || - equalFunc(arrValue, othValue, bitmask, customizer, stack) - )) { - result = false; - break; - } - } - stack['delete'](array); - stack['delete'](other); - return result; -} - -/** - * A specialized version of `baseIsEqualDeep` for comparing objects of - * the same `toStringTag`. - * - * **Note:** This function only supports comparing values with tags of - * `Boolean`, `Date`, `Error`, `Number`, `RegExp`, or `String`. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {string} tag The `toStringTag` of the objects to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ -function equalByTag(object, other, tag, bitmask, customizer, equalFunc, stack) { - switch (tag) { - case dataViewTag: - if ((object.byteLength != other.byteLength) || - (object.byteOffset != other.byteOffset)) { - return false; - } - object = object.buffer; - other = other.buffer; - - case arrayBufferTag: - if ((object.byteLength != other.byteLength) || - !equalFunc(new Uint8Array(object), new Uint8Array(other))) { - return false; - } - return true; - - case boolTag: - case dateTag: - case numberTag: - // Coerce booleans to `1` or `0` and dates to milliseconds. - // Invalid dates are coerced to `NaN`. - return eq(+object, +other); - - case errorTag: - return object.name == other.name && object.message == other.message; - - case regexpTag: - case stringTag: - // Coerce regexes to strings and treat strings, primitives and objects, - // as equal. See http://www.ecma-international.org/ecma-262/7.0/#sec-regexp.prototype.tostring - // for more details. - return object == (other + ''); - - case mapTag: - var convert = mapToArray; - - case setTag: - var isPartial = bitmask & COMPARE_PARTIAL_FLAG; - convert || (convert = setToArray); - - if (object.size != other.size && !isPartial) { - return false; - } - // Assume cyclic values are equal. - var stacked = stack.get(object); - if (stacked) { - return stacked == other; - } - bitmask |= COMPARE_UNORDERED_FLAG; - - // Recursively compare objects (susceptible to call stack limits). - stack.set(object, other); - var result = equalArrays(convert(object), convert(other), bitmask, customizer, equalFunc, stack); - stack['delete'](object); - return result; - - case symbolTag: - if (symbolValueOf) { - return symbolValueOf.call(object) == symbolValueOf.call(other); - } - } - return false; -} - -/** - * A specialized version of `baseIsEqualDeep` for objects with support for - * partial deep comparisons. - * - * @private - * @param {Object} object The object to compare. - * @param {Object} other The other object to compare. - * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details. - * @param {Function} customizer The function to customize comparisons. - * @param {Function} equalFunc The function to determine equivalents of values. - * @param {Object} stack Tracks traversed `object` and `other` objects. - * @returns {boolean} Returns `true` if the objects are equivalent, else `false`. - */ -function equalObjects(object, other, bitmask, customizer, equalFunc, stack) { - var isPartial = bitmask & COMPARE_PARTIAL_FLAG, - objProps = getAllKeys(object), - objLength = objProps.length, - othProps = getAllKeys(other), - othLength = othProps.length; - - if (objLength != othLength && !isPartial) { - return false; - } - var index = objLength; - while (index--) { - var key = objProps[index]; - if (!(isPartial ? key in other : hasOwnProperty.call(other, key))) { - return false; - } - } - // Assume cyclic values are equal. - var stacked = stack.get(object); - if (stacked && stack.get(other)) { - return stacked == other; - } - var result = true; - stack.set(object, other); - stack.set(other, object); - - var skipCtor = isPartial; - while (++index < objLength) { - key = objProps[index]; - var objValue = object[key], - othValue = other[key]; - - if (customizer) { - var compared = isPartial - ? customizer(othValue, objValue, key, other, object, stack) - : customizer(objValue, othValue, key, object, other, stack); - } - // Recursively compare objects (susceptible to call stack limits). - if (!(compared === undefined - ? (objValue === othValue || equalFunc(objValue, othValue, bitmask, customizer, stack)) - : compared - )) { - result = false; - break; - } - skipCtor || (skipCtor = key == 'constructor'); - } - if (result && !skipCtor) { - var objCtor = object.constructor, - othCtor = other.constructor; - - // Non `Object` object instances with different constructors are not equal. - if (objCtor != othCtor && - ('constructor' in object && 'constructor' in other) && - !(typeof objCtor == 'function' && objCtor instanceof objCtor && - typeof othCtor == 'function' && othCtor instanceof othCtor)) { - result = false; - } - } - stack['delete'](object); - stack['delete'](other); - return result; -} - -/** - * Creates an array of own enumerable property names and symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names and symbols. - */ -function getAllKeys(object) { - return baseGetAllKeys(object, keys, getSymbols); -} - -/** - * Gets the data for `map`. - * - * @private - * @param {Object} map The map to query. - * @param {string} key The reference key. - * @returns {*} Returns the map data. - */ -function getMapData(map, key) { - var data = map.__data__; - return isKeyable(key) - ? data[typeof key == 'string' ? 'string' : 'hash'] - : data.map; -} - -/** - * Gets the native function at `key` of `object`. - * - * @private - * @param {Object} object The object to query. - * @param {string} key The key of the method to get. - * @returns {*} Returns the function if it's native, else `undefined`. - */ -function getNative(object, key) { - var value = getValue(object, key); - return baseIsNative(value) ? value : undefined; -} - -/** - * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the raw `toStringTag`. - */ -function getRawTag(value) { - var isOwn = hasOwnProperty.call(value, symToStringTag), - tag = value[symToStringTag]; - - try { - value[symToStringTag] = undefined; - var unmasked = true; - } catch (e) {} - - var result = nativeObjectToString.call(value); - if (unmasked) { - if (isOwn) { - value[symToStringTag] = tag; - } else { - delete value[symToStringTag]; - } - } - return result; -} - -/** - * Creates an array of the own enumerable symbols of `object`. - * - * @private - * @param {Object} object The object to query. - * @returns {Array} Returns the array of symbols. - */ -var getSymbols = !nativeGetSymbols ? stubArray : function(object) { - if (object == null) { - return []; - } - object = Object(object); - return arrayFilter(nativeGetSymbols(object), function(symbol) { - return propertyIsEnumerable.call(object, symbol); - }); -}; - -/** - * Gets the `toStringTag` of `value`. - * - * @private - * @param {*} value The value to query. - * @returns {string} Returns the `toStringTag`. - */ -var getTag = baseGetTag; - -// Fallback for data views, maps, sets, and weak maps in IE 11 and promises in Node.js < 6. -if ((DataView && getTag(new DataView(new ArrayBuffer(1))) != dataViewTag) || - (Map && getTag(new Map) != mapTag) || - (Promise && getTag(Promise.resolve()) != promiseTag) || - (Set && getTag(new Set) != setTag) || - (WeakMap && getTag(new WeakMap) != weakMapTag)) { - getTag = function(value) { - var result = baseGetTag(value), - Ctor = result == objectTag ? value.constructor : undefined, - ctorString = Ctor ? toSource(Ctor) : ''; - - if (ctorString) { - switch (ctorString) { - case dataViewCtorString: return dataViewTag; - case mapCtorString: return mapTag; - case promiseCtorString: return promiseTag; - case setCtorString: return setTag; - case weakMapCtorString: return weakMapTag; - } - } - return result; - }; -} - -/** - * Checks if `value` is a valid array-like index. - * - * @private - * @param {*} value The value to check. - * @param {number} [length=MAX_SAFE_INTEGER] The upper bounds of a valid index. - * @returns {boolean} Returns `true` if `value` is a valid index, else `false`. - */ -function isIndex(value, length) { - length = length == null ? MAX_SAFE_INTEGER : length; - return !!length && - (typeof value == 'number' || reIsUint.test(value)) && - (value > -1 && value % 1 == 0 && value < length); -} - -/** - * Checks if `value` is suitable for use as unique object key. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is suitable, else `false`. - */ -function isKeyable(value) { - var type = typeof value; - return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean') - ? (value !== '__proto__') - : (value === null); -} - -/** - * Checks if `func` has its source masked. - * - * @private - * @param {Function} func The function to check. - * @returns {boolean} Returns `true` if `func` is masked, else `false`. - */ -function isMasked(func) { - return !!maskSrcKey && (maskSrcKey in func); -} - -/** - * Checks if `value` is likely a prototype object. - * - * @private - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a prototype, else `false`. - */ -function isPrototype(value) { - var Ctor = value && value.constructor, - proto = (typeof Ctor == 'function' && Ctor.prototype) || objectProto; - - return value === proto; -} - -/** - * Converts `value` to a string using `Object.prototype.toString`. - * - * @private - * @param {*} value The value to convert. - * @returns {string} Returns the converted string. - */ -function objectToString(value) { - return nativeObjectToString.call(value); -} - -/** - * Converts `func` to its source code. - * - * @private - * @param {Function} func The function to convert. - * @returns {string} Returns the source code. - */ -function toSource(func) { - if (func != null) { - try { - return funcToString.call(func); - } catch (e) {} - try { - return (func + ''); - } catch (e) {} - } - return ''; -} - -/** - * Performs a - * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) - * comparison between two values to determine if they are equivalent. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * var object = { 'a': 1 }; - * var other = { 'a': 1 }; - * - * _.eq(object, object); - * // => true - * - * _.eq(object, other); - * // => false - * - * _.eq('a', 'a'); - * // => true - * - * _.eq('a', Object('a')); - * // => false - * - * _.eq(NaN, NaN); - * // => true - */ -function eq(value, other) { - return value === other || (value !== value && other !== other); -} - -/** - * Checks if `value` is likely an `arguments` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an `arguments` object, - * else `false`. - * @example - * - * _.isArguments(function() { return arguments; }()); - * // => true - * - * _.isArguments([1, 2, 3]); - * // => false - */ -var isArguments = baseIsArguments(function() { return arguments; }()) ? baseIsArguments : function(value) { - return isObjectLike(value) && hasOwnProperty.call(value, 'callee') && - !propertyIsEnumerable.call(value, 'callee'); -}; - -/** - * Checks if `value` is classified as an `Array` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an array, else `false`. - * @example - * - * _.isArray([1, 2, 3]); - * // => true - * - * _.isArray(document.body.children); - * // => false - * - * _.isArray('abc'); - * // => false - * - * _.isArray(_.noop); - * // => false - */ -var isArray = Array.isArray; - -/** - * Checks if `value` is array-like. A value is considered array-like if it's - * not a function and has a `value.length` that's an integer greater than or - * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`. - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is array-like, else `false`. - * @example - * - * _.isArrayLike([1, 2, 3]); - * // => true - * - * _.isArrayLike(document.body.children); - * // => true - * - * _.isArrayLike('abc'); - * // => true - * - * _.isArrayLike(_.noop); - * // => false - */ -function isArrayLike(value) { - return value != null && isLength(value.length) && !isFunction(value); -} - -/** - * Checks if `value` is a buffer. - * - * @static - * @memberOf _ - * @since 4.3.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a buffer, else `false`. - * @example - * - * _.isBuffer(new Buffer(2)); - * // => true - * - * _.isBuffer(new Uint8Array(2)); - * // => false - */ -var isBuffer = nativeIsBuffer || stubFalse; - -/** - * Performs a deep comparison between two values to determine if they are - * equivalent. - * - * **Note:** This method supports comparing arrays, array buffers, booleans, - * date objects, error objects, maps, numbers, `Object` objects, regexes, - * sets, strings, symbols, and typed arrays. `Object` objects are compared - * by their own, not inherited, enumerable properties. Functions and DOM - * nodes are compared by strict equality, i.e. `===`. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to compare. - * @param {*} other The other value to compare. - * @returns {boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * var object = { 'a': 1 }; - * var other = { 'a': 1 }; - * - * _.isEqual(object, other); - * // => true - * - * object === other; - * // => false - */ -function isEqual(value, other) { - return baseIsEqual(value, other); -} - -/** - * Checks if `value` is classified as a `Function` object. - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a function, else `false`. - * @example - * - * _.isFunction(_); - * // => true - * - * _.isFunction(/abc/); - * // => false - */ -function isFunction(value) { - if (!isObject(value)) { - return false; - } - // The use of `Object#toString` avoids issues with the `typeof` operator - // in Safari 9 which returns 'object' for typed arrays and other constructors. - var tag = baseGetTag(value); - return tag == funcTag || tag == genTag || tag == asyncTag || tag == proxyTag; -} - -/** - * Checks if `value` is a valid array-like length. - * - * **Note:** This method is loosely based on - * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength). - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a valid length, else `false`. - * @example - * - * _.isLength(3); - * // => true - * - * _.isLength(Number.MIN_VALUE); - * // => false - * - * _.isLength(Infinity); - * // => false - * - * _.isLength('3'); - * // => false - */ -function isLength(value) { - return typeof value == 'number' && - value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER; -} - -/** - * Checks if `value` is the - * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) - * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) - * - * @static - * @memberOf _ - * @since 0.1.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is an object, else `false`. - * @example - * - * _.isObject({}); - * // => true - * - * _.isObject([1, 2, 3]); - * // => true - * - * _.isObject(_.noop); - * // => true - * - * _.isObject(null); - * // => false - */ -function isObject(value) { - var type = typeof value; - return value != null && (type == 'object' || type == 'function'); -} - -/** - * Checks if `value` is object-like. A value is object-like if it's not `null` - * and has a `typeof` result of "object". - * - * @static - * @memberOf _ - * @since 4.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is object-like, else `false`. - * @example - * - * _.isObjectLike({}); - * // => true - * - * _.isObjectLike([1, 2, 3]); - * // => true - * - * _.isObjectLike(_.noop); - * // => false - * - * _.isObjectLike(null); - * // => false - */ -function isObjectLike(value) { - return value != null && typeof value == 'object'; -} - -/** - * Checks if `value` is classified as a typed array. - * - * @static - * @memberOf _ - * @since 3.0.0 - * @category Lang - * @param {*} value The value to check. - * @returns {boolean} Returns `true` if `value` is a typed array, else `false`. - * @example - * - * _.isTypedArray(new Uint8Array); - * // => true - * - * _.isTypedArray([]); - * // => false - */ -var isTypedArray = nodeIsTypedArray ? baseUnary(nodeIsTypedArray) : baseIsTypedArray; - -/** - * Creates an array of the own enumerable property names of `object`. - * - * **Note:** Non-object values are coerced to objects. See the - * [ES spec](http://ecma-international.org/ecma-262/7.0/#sec-object.keys) - * for more details. - * - * @static - * @since 0.1.0 - * @memberOf _ - * @category Object - * @param {Object} object The object to query. - * @returns {Array} Returns the array of property names. - * @example - * - * function Foo() { - * this.a = 1; - * this.b = 2; - * } - * - * Foo.prototype.c = 3; - * - * _.keys(new Foo); - * // => ['a', 'b'] (iteration order is not guaranteed) - * - * _.keys('hi'); - * // => ['0', '1'] - */ -function keys(object) { - return isArrayLike(object) ? arrayLikeKeys(object) : baseKeys(object); -} - -/** - * This method returns a new empty array. - * - * @static - * @memberOf _ - * @since 4.13.0 - * @category Util - * @returns {Array} Returns the new empty array. - * @example - * - * var arrays = _.times(2, _.stubArray); - * - * console.log(arrays); - * // => [[], []] - * - * console.log(arrays[0] === arrays[1]); - * // => false - */ -function stubArray() { - return []; -} - -/** - * This method returns `false`. - * - * @static - * @memberOf _ - * @since 4.13.0 - * @category Util - * @returns {boolean} Returns `false`. - * @example - * - * _.times(2, _.stubFalse); - * // => [false, false] - */ -function stubFalse() { - return false; -} - -module.exports = isEqual; - - /***/ }), /***/ 770: @@ -32490,8 +30634,8 @@ function qstring(str) { /******/ } /******/ // Create a new module (and put it into the cache) /******/ var module = __webpack_module_cache__[moduleId] = { -/******/ id: moduleId, -/******/ loaded: false, +/******/ // no module.id needed +/******/ // no module.loaded needed /******/ exports: {} /******/ }; /******/ @@ -32504,23 +30648,11 @@ function qstring(str) { /******/ if(threw) delete __webpack_module_cache__[moduleId]; /******/ } /******/ -/******/ // Flag the module as loaded -/******/ module.loaded = true; -/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ /************************************************************************/ -/******/ /* webpack/runtime/node module decorator */ -/******/ (() => { -/******/ __nccwpck_require__.nmd = (module) => { -/******/ module.paths = []; -/******/ if (!module.children) module.children = []; -/******/ return module; -/******/ }; -/******/ })(); -/******/ /******/ /* webpack/runtime/compat */ /******/ /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = new URL('.', import.meta.url).pathname.slice(import.meta.url.match(/^file:\/\/\/\w:/) ? 1 : 0, -1) + "/"; @@ -39874,6 +38006,18 @@ function retry(octokit, octokitOptions) { retry.VERSION = plugin_retry_dist_bundle_VERSION; +;// CONCATENATED MODULE: ./lib/api/add-labels.js + +const addLabels = async (client, prNumber, labels) => { + await client.rest.issues.addLabels({ + owner: github_context.repo.owner, + repo: github_context.repo.repo, + issue_number: prNumber, + labels, + request: { retries: 0 } + }); +}; + ;// CONCATENATED MODULE: ./lib/api/get-changed-files.js @@ -45922,15 +44066,18 @@ function configUsesChangedFiles(matchConfigs) { return false; } -;// CONCATENATED MODULE: ./lib/api/set-labels.js - -const setLabels = async (client, prNumber, labels) => { - await client.rest.issues.setLabels({ - owner: github_context.repo.owner, - repo: github_context.repo.repo, - issue_number: prNumber, - labels: labels - }); +;// CONCATENATED MODULE: ./lib/api/remove-labels.js +const REMOVE_LABELS_MUTATION = ` + mutation RemoveLabels($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable( + input: {labelableId: $labelableId, labelIds: $labelIds} + ) { + clientMutationId + } + } +`; +const removeLabels = async (client, labelableId, labelIds) => { + await client.graphql(REMOVE_LABELS_MUTATION, { labelableId, labelIds }); }; ;// CONCATENATED MODULE: ./lib/api/index.js @@ -45941,8 +44088,7 @@ const setLabels = async (client, prNumber, labels) => { -// EXTERNAL MODULE: ./node_modules/lodash.isequal/index.js -var lodash_isequal = __nccwpck_require__(9471); + ;// CONCATENATED MODULE: ./lib/get-inputs/get-pr-numbers.js @@ -45995,7 +44141,6 @@ const getInputs = () => ({ - // GitHub Issues cannot have more than 100 labels const GITHUB_MAX_LABELS = 100; const run = () => labeler().catch(error => { @@ -46054,46 +44199,44 @@ async function labeler() { } const labelsToApply = [...allLabels].slice(0, GITHUB_MAX_LABELS); const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); - let finalLabels = labelsToApply; - let newLabels = []; + 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)); try { - if (!lodash_isequal(labelsToApply, preexistingLabels)) { - // Fetch the latest labels for the PR - const latestLabels = []; - // 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)); + 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}`); } - // Labels added manually during the run (not in first snapshot) - const manualAddedDuringRun = latestLabels.filter(l => !preexistingLabels.includes(l)); - // 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); - await setLabels(client, pullRequest.number, finalLabels); - newLabels = finalLabels.filter(l => !preexistingLabels.includes(l)); + try { + await removeLabels(client, labelableId, staleLabels.map(label => label.node_id)); + } + catch (error) { + throw new Error(`Failed to remove configured labels '${staleLabels.map(label => label.name).join("', '")}' from PR #${pullRequest.number}`, { cause: error }); + } + } + if (newLabels.length) { + await addLabels(client, pullRequest.number, newLabels); } } catch (error) { - if (error.name === 'HttpError' && - error.status === 403 && - error.message.toLowerCase().includes('unauthorized')) { - throw new Error(`Failed to set labels for PR #${pullRequest.number}. The workflow does not have permission to create labels. ` + + const apiError = error.cause ?? error; + if (apiError.name === 'HttpError' && + apiError.status === 403 && + apiError.message.toLowerCase().includes('unauthorized')) { + throw new Error(`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') { + else if (apiError.name !== 'HttpError' || + apiError.message !== 'Resource not accessible by integration') { throw error; } warning(`The action requires 'issues: write' permission to create new labels or 'pull-requests: write' permission to add existing labels to pull requests. ` + `For more information, refer to the action documentation: https://github.com/actions/labeler#recommended-permissions`, { title: `${process.env['GITHUB_ACTION_REPOSITORY']} running under '${github_context.eventName}' is misconfigured` }); - setFailed(error.message); + setFailed(apiError.message); return; } setOutput('new-labels', newLabels.join(',')); diff --git a/package-lock.json b/package-lock.json index 9b163cc89..ec66259f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6c2a694b9..a112779cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/set-labels.ts b/src/api/add-labels.ts similarity index 70% rename from src/api/set-labels.ts rename to src/api/add-labels.ts index d45fadad0..2e49e875a 100644 --- a/src/api/set-labels.ts +++ b/src/api/add-labels.ts @@ -1,15 +1,16 @@ import * as github from '@actions/github'; import {ClientType} from './types.js'; -export const setLabels = async ( +export const addLabels = async ( client: ClientType, prNumber: number, labels: string[] ) => { - await client.rest.issues.setLabels({ + await client.rest.issues.addLabels({ owner: github.context.repo.owner, repo: github.context.repo.repo, issue_number: prNumber, - labels: labels + labels, + request: {retries: 0} }); }; diff --git a/src/api/index.ts b/src/api/index.ts index 1d96fc5c3..4507cff1e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; diff --git a/src/api/remove-labels.ts b/src/api/remove-labels.ts new file mode 100644 index 000000000..9d7fa04bd --- /dev/null +++ b/src/api/remove-labels.ts @@ -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}); +}; diff --git a/src/labeler.ts b/src/labeler.ts index bcd32b5d9..fe336d27a 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -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[] = []; + 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) + ); 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)); + 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}` + ); } - // Labels added manually during the run (not in first snapshot) - const manualAddedDuringRun = latestLabels.filter( - l => !preexistingLabels.includes(l) - ); - - // 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); - - 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; } From 0367cca6d3cd79fd4ac0fae4ddc87e591ee42b6d Mon Sep 17 00:00:00 2001 From: Logan Rosen Date: Wed, 22 Jul 2026 15:06:55 -0400 Subject: [PATCH 2/2] Reconcile ambiguous label addition failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44452010-1cd5-4e90-8abe-f03547c04182 --- __tests__/add-labels.test.ts | 94 ++++++++++++++++++++++++++++++++++++ dist/index.js | 42 ++++++++++++++-- src/api/add-labels.ts | 48 ++++++++++++++++-- 3 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 __tests__/add-labels.test.ts diff --git a/__tests__/add-labels.test.ts b/__tests__/add-labels.test.ts new file mode 100644 index 000000000..85595e133 --- /dev/null +++ b/__tests__/add-labels.test.ts @@ -0,0 +1,94 @@ +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(); + const listLabelsOnIssueMock = jest.fn(); + 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'}] + }); + + await expect( + addLabels(client, 123, ['bug', 'documentation']) + ).resolves.toBeUndefined(); + expect(listLabelsOnIssueMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + per_page: 100, + 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'}]}); + + await expect(addLabels(client, 123, ['bug', 'documentation'])).rejects.toBe( + serverError + ); + }); + + 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); + }); +}); diff --git a/dist/index.js b/dist/index.js index e0caa953f..778a5829e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -38008,14 +38008,46 @@ retry.VERSION = plugin_retry_dist_bundle_VERSION; ;// CONCATENATED MODULE: ./lib/api/add-labels.js +const isServerError = (error) => typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number' && + error.status >= 500 && + error.status < 600; const addLabels = async (client, prNumber, labels) => { - await client.rest.issues.addLabels({ + const request = { owner: github_context.repo.owner, repo: github_context.repo.repo, - issue_number: prNumber, - labels, - request: { retries: 0 } - }); + issue_number: prNumber + }; + try { + await client.rest.issues.addLabels({ + ...request, + labels, + request: { retries: 0 } + }); + } + catch (error) { + if (!isServerError(error)) { + throw error; + } + let currentLabels; + try { + currentLabels = await client.rest.issues.listLabelsOnIssue({ + ...request, + per_page: 100, + request: { retries: 0 } + }); + } + catch { + throw error; + } + const currentLabelNames = new Set(currentLabels.data.map(label => label.name.toLowerCase())); + if (labels.every(label => currentLabelNames.has(label.toLowerCase()))) { + return; + } + throw error; + } }; ;// CONCATENATED MODULE: ./lib/api/get-changed-files.js diff --git a/src/api/add-labels.ts b/src/api/add-labels.ts index 2e49e875a..c7ed57669 100644 --- a/src/api/add-labels.ts +++ b/src/api/add-labels.ts @@ -1,16 +1,54 @@ 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[] ) => { - await client.rest.issues.addLabels({ + const request = { owner: github.context.repo.owner, repo: github.context.repo.repo, - issue_number: prNumber, - labels, - request: {retries: 0} - }); + issue_number: prNumber + }; + + try { + await client.rest.issues.addLabels({ + ...request, + labels, + request: {retries: 0} + }); + } catch (error: unknown) { + if (!isServerError(error)) { + throw error; + } + + let currentLabels; + try { + currentLabels = await client.rest.issues.listLabelsOnIssue({ + ...request, + per_page: 100, + request: {retries: 0} + }); + } catch { + throw error; + } + + const currentLabelNames = new Set( + currentLabels.data.map(label => label.name.toLowerCase()) + ); + if (labels.every(label => currentLabelNames.has(label.toLowerCase()))) { + return; + } + + throw error; + } };