Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 0 additions & 58 deletions .licenses/npm/lodash.isequal.dep.yml

This file was deleted.

13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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"
Expand Down
94 changes: 94 additions & 0 deletions __tests__/add-labels.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>();
const listLabelsOnIssueMock = jest.fn<any>();
const client = {
rest: {
issues: {
addLabels: addLabelsMock,
listLabelsOnIssue: listLabelsOnIssueMock
}
}
} as ClientType;

return {client, addLabelsMock, listLabelsOnIssueMock};
};

describe('addLabels', () => {
it('does not verify a successful addition', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
addLabelsMock.mockResolvedValue({data: []});

await addLabels(client, 123, ['bug']);

expect(addLabelsMock).toHaveBeenCalledWith({
owner: 'monalisa',
repo: 'helloworld',
issue_number: 123,
labels: ['bug'],
request: {retries: 0}
});
expect(listLabelsOnIssueMock).not.toHaveBeenCalled();
});

it('accepts a server error when every requested label was committed', async () => {
const {client, addLabelsMock, listLabelsOnIssueMock} = createClient();
const serverError = Object.assign(new Error('Bad Gateway'), {status: 502});
addLabelsMock.mockRejectedValue(serverError);
listLabelsOnIssueMock.mockResolvedValue({
data: [{name: 'BUG'}, {name: 'documentation'}]
});

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);
});
});
41 changes: 36 additions & 5 deletions __tests__/labeler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
// Define API mock functions at module level
const getPullRequestsMock = jest.fn<any>();
const getLabelConfigsMock = jest.fn<any>();
const setLabelsMock = jest.fn<any>();
const addLabelsMock = jest.fn<any>();
const removeLabelsMock = jest.fn<any>();
const getChangedFilesMock = jest.fn<any>();
const getContentMock = jest.fn<any>();

Expand Down Expand Up @@ -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
}));
Expand Down Expand Up @@ -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'
Expand All @@ -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`.
Expand All @@ -508,7 +510,7 @@ describe('labeler error handling', () => {
name: 'HttpError',
message: 'Resource not accessible by integration'
};
setLabelsMock.mockRejectedValue(error);
addLabelsMock.mockRejectedValue(error);

await labeler();

Expand All @@ -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();
});
});
Loading