diff --git a/.gitignore b/.gitignore index d7590431c..7fffad69e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .DS_Store node_modules/ lib/ -.idea \ No newline at end of file +.idea diff --git a/README.md b/README.md index e09bc6232..780c26cc7 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,9 @@ Various inputs are defined in [`action.yml`](action.yml) to let you configure th | `configuration-path` | The path to the label configuration file | `.github/labeler.yml` | | `sync-labels` | Whether or not to remove labels when matching files are reverted or no longer changed by the PR | `false` | | `dot` | Whether or not to auto-include paths starting with dot (e.g. `.github`) | `false` | +| `pr-number` | The number(s) of pull request to update, rather than detecting from the workflow context | N/A | -When `dot` is disabled and you want to include _all_ files in a folder: +When `dot` is disabled, and you want to include _all_ files in a folder: ```yml label1: @@ -142,6 +143,35 @@ label1: - path/to/folder/** ``` +##### Example workflow specifying Pull request numbers + +```yml +name: "Label Previous Pull Requests" +on: + schedule: + - cron: "0 1 * * 1" + +jobs: + triage: + permissions: + contents: read + pull-requests: write + + runs-on: ubuntu-latest + steps: + + # Label PRs 1, 2, and 3 + - uses: actions/labeler@v4 + with: + pr-number: | + 1 + 2 + 3 +``` + +**Note:** in normal usage the `pr-number` input is not required as the action will detect the PR number from the workflow context. + + #### Outputs Labeler provides the following outputs: diff --git a/__mocks__/@actions/github.ts b/__mocks__/@actions/github.ts index ff41ffb55..9e857c537 100644 --- a/__mocks__/@actions/github.ts +++ b/__mocks__/@actions/github.ts @@ -16,7 +16,11 @@ const mockApi = { setLabels: jest.fn() }, pulls: { - get: jest.fn().mockResolvedValue({}), + get: jest.fn().mockResolvedValue({ + data: { + labels: [] + } + }), listFiles: { endpoint: { merge: jest.fn().mockReturnValue({}) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 781a87ea2..94023dc89 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -25,11 +25,15 @@ const configureInput = ( 'configuration-path': string; 'sync-labels': boolean; dot: boolean; + 'pr-number': string[]; }> ) => { jest .spyOn(core, 'getInput') .mockImplementation((name: string, ...opts) => mockInput[name]); + jest + .spyOn(core, 'getMultilineInput') + .mockImplementation((name: string, ...opts) => mockInput[name]); jest .spyOn(core, 'getBooleanInput') .mockImplementation((name: string, ...opts) => mockInput[name]); @@ -209,6 +213,88 @@ describe('run', () => { expect(setOutputSpy).toHaveBeenCalledWith('new-labels', ''); expect(setOutputSpy).toHaveBeenCalledWith('all-labels', allLabels); }); + + it('(with pr-number: array of one item, uses the PR number specified in the parameters', async () => { + configureInput({ + 'repo-token': 'foo', + 'configuration-path': 'bar', + 'pr-number': ['104'] + }); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles('foo.pdf'); + + getPullMock.mockResolvedValue({ + data: { + labels: [{name: 'manually-added'}] + } + }); + + await run(); + + expect(setLabelsMock).toHaveBeenCalledTimes(1); + expect(setLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 104, + labels: ['manually-added', 'touched-a-pdf-file'] + }); + expect(setOutputSpy).toHaveBeenCalledWith( + 'new-labels', + 'touched-a-pdf-file' + ); + expect(setOutputSpy).toHaveBeenCalledWith( + 'all-labels', + 'manually-added,touched-a-pdf-file' + ); + }); + + it('(with pr-number: array of two items, uses the PR number specified in the parameters', async () => { + configureInput({ + 'repo-token': 'foo', + 'configuration-path': 'bar', + 'pr-number': ['104', '150'] + }); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles('foo.pdf'); + + getPullMock.mockResolvedValueOnce({ + data: { + labels: [{name: 'manually-added'}] + } + }); + + getPullMock.mockResolvedValueOnce({ + data: { + labels: [] + } + }); + + await run(); + + expect(setLabelsMock).toHaveBeenCalledTimes(2); + expect(setLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 104, + labels: ['manually-added', 'touched-a-pdf-file'] + }); + expect(setLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 150, + labels: ['touched-a-pdf-file'] + }); + expect(setOutputSpy).toHaveBeenCalledWith( + 'new-labels', + 'touched-a-pdf-file' + ); + expect(setOutputSpy).toHaveBeenCalledWith( + 'all-labels', + 'manually-added,touched-a-pdf-file' + ); + }); }); function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void { diff --git a/action.yml b/action.yml index 396cb8f1a..3a944394a 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,9 @@ inputs: description: 'Whether or not to auto-include paths starting with dot (e.g. `.github`)' default: false required: false + pr-number: + description: 'The pull request number(s)' + required: false outputs: new-labels: diff --git a/dist/index.js b/dist/index.js index 20509a5f1..d1ae9b15c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -54,55 +54,66 @@ function run() { const configPath = core.getInput('configuration-path', { required: true }); const syncLabels = !!core.getInput('sync-labels'); const dot = core.getBooleanInput('dot'); - const prNumber = getPrNumber(); - if (!prNumber) { - core.info('Could not get pull request number from context, exiting'); + const prNumbers = getPrNumbers(); + if (!prNumbers.length) { + core.warning('Could not get pull request number(s), exiting'); return; } const client = github.getOctokit(token, {}, pluginRetry.retry); - const { data: pullRequest } = yield client.rest.pulls.get({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - pull_number: prNumber - }); - core.debug(`fetching changed files for pr #${prNumber}`); - const changedFiles = yield getChangedFiles(client, prNumber); - const labelGlobs = yield getLabelGlobs(client, configPath); - const preexistingLabels = pullRequest.labels.map(l => l.name); - const allLabels = new Set(preexistingLabels); - for (const [label, globs] of labelGlobs.entries()) { - core.debug(`processing ${label}`); - if (checkGlobs(changedFiles, globs, dot)) { - allLabels.add(label); - } - else if (syncLabels) { - allLabels.delete(label); + for (const prNumber of prNumbers) { + core.debug(`looking for pr #${prNumber}`); + let pullRequest; + try { + const result = yield client.rest.pulls.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + pullRequest = result.data; } - } - const labelsToAdd = [...allLabels].slice(0, GITHUB_MAX_LABELS); - const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); - try { - let newLabels = []; - if (!isListEqual(labelsToAdd, preexistingLabels)) { - yield setLabels(client, prNumber, labelsToAdd); - newLabels = labelsToAdd.filter(l => !preexistingLabels.includes(l)); + catch (error) { + core.warning(`Could not find pull request #${prNumber}, skipping`); + continue; } - core.setOutput('new-labels', newLabels.join(',')); - core.setOutput('all-labels', labelsToAdd.join(',')); - if (excessLabels.length) { - core.warning(`Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join(', ')}`, { title: 'Label limit for a PR exceeded' }); + core.debug(`fetching changed files for pr #${prNumber}`); + const changedFiles = yield getChangedFiles(client, prNumber); + const labelGlobs = yield getLabelGlobs(client, configPath); + const preexistingLabels = pullRequest.labels.map(l => l.name); + const allLabels = new Set(preexistingLabels); + for (const [label, globs] of labelGlobs.entries()) { + core.debug(`processing ${label}`); + if (checkGlobs(changedFiles, globs, dot)) { + allLabels.add(label); + } + else if (syncLabels) { + allLabels.delete(label); + } } - } - catch (error) { - if (error.name === 'HttpError' && - error.message === 'Resource not accessible by integration') { - core.warning(`The action requires write permission to add labels to pull requests. For more information please refer to the action documentation: https://github.com/actions/labeler#permissions`, { - title: `${process.env['GITHUB_ACTION_REPOSITORY']} running under '${github.context.eventName}' is misconfigured` - }); - core.setFailed(error.message); + const labelsToAdd = [...allLabels].slice(0, GITHUB_MAX_LABELS); + const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); + try { + let newLabels = []; + if (!isListEqual(labelsToAdd, preexistingLabels)) { + yield setLabels(client, prNumber, labelsToAdd); + newLabels = labelsToAdd.filter(l => !preexistingLabels.includes(l)); + } + core.setOutput('new-labels', newLabels.join(',')); + core.setOutput('all-labels', labelsToAdd.join(',')); + if (excessLabels.length) { + core.warning(`Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join(', ')}`, { title: 'Label limit for a PR exceeded' }); + } } - else { - throw error; + catch (error) { + if (error.name === 'HttpError' && + error.message === 'Resource not accessible by integration') { + core.warning(`The action requires write permission to add labels to pull requests. For more information please refer to the action documentation: https://github.com/actions/labeler#permissions`, { + title: `${process.env['GITHUB_ACTION_REPOSITORY']} running under '${github.context.eventName}' is misconfigured` + }); + core.setFailed(error.message); + } + else { + throw error; + } } } } @@ -113,12 +124,26 @@ function run() { }); } exports.run = run; -function getPrNumber() { +function getPrNumbers() { + const pullRequestNumbers = core.getMultilineInput('pr-number'); + if (pullRequestNumbers && pullRequestNumbers.length) { + const prNumbers = []; + for (const prNumber of pullRequestNumbers) { + const prNumberInt = parseInt(prNumber, 10); + if (isNaN(prNumberInt) || prNumberInt <= 0) { + core.warning(`'${prNumber}' is not a valid pull request number`); + } + else { + prNumbers.push(prNumberInt); + } + } + return prNumbers; + } const pullRequest = github.context.payload.pull_request; if (!pullRequest) { - return undefined; + return []; } - return pullRequest.number; + return [pullRequest.number]; } function getChangedFiles(client, prNumber) { return __awaiter(this, void 0, void 0, function* () { diff --git a/src/labeler.ts b/src/labeler.ts index d1dc601f2..c2c53da5c 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -22,75 +22,83 @@ export async function run() { const syncLabels = !!core.getInput('sync-labels'); const dot = core.getBooleanInput('dot'); - const prNumber = getPrNumber(); - if (!prNumber) { - core.info('Could not get pull request number from context, exiting'); + const prNumbers = getPrNumbers(); + if (!prNumbers.length) { + core.warning('Could not get pull request number(s), exiting'); return; } const client: ClientType = github.getOctokit(token, {}, pluginRetry.retry); - const {data: pullRequest} = await client.rest.pulls.get({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - pull_number: prNumber - }); - - core.debug(`fetching changed files for pr #${prNumber}`); - const changedFiles: string[] = await getChangedFiles(client, prNumber); - const labelGlobs: Map = await getLabelGlobs( - client, - configPath - ); - - const preexistingLabels = pullRequest.labels.map(l => l.name); - const allLabels: Set = new Set(preexistingLabels); - - for (const [label, globs] of labelGlobs.entries()) { - core.debug(`processing ${label}`); - if (checkGlobs(changedFiles, globs, dot)) { - allLabels.add(label); - } else if (syncLabels) { - allLabels.delete(label); + for (const prNumber of prNumbers) { + core.debug(`looking for pr #${prNumber}`); + let pullRequest: any; + try { + const result = await client.rest.pulls.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + pullRequest = result.data; + } catch (error: any) { + core.warning(`Could not find pull request #${prNumber}, skipping`); + continue; } - } - - const labelsToAdd = [...allLabels].slice(0, GITHUB_MAX_LABELS); - const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); - try { - let newLabels: string[] = []; - - if (!isListEqual(labelsToAdd, preexistingLabels)) { - await setLabels(client, prNumber, labelsToAdd); - newLabels = labelsToAdd.filter(l => !preexistingLabels.includes(l)); + core.debug(`fetching changed files for pr #${prNumber}`); + const changedFiles: string[] = await getChangedFiles(client, prNumber); + const labelGlobs: Map = + await getLabelGlobs(client, configPath); + + const preexistingLabels = pullRequest.labels.map(l => l.name); + const allLabels: Set = new Set(preexistingLabels); + + for (const [label, globs] of labelGlobs.entries()) { + core.debug(`processing ${label}`); + if (checkGlobs(changedFiles, globs, dot)) { + allLabels.add(label); + } else if (syncLabels) { + allLabels.delete(label); + } } - core.setOutput('new-labels', newLabels.join(',')); - core.setOutput('all-labels', labelsToAdd.join(',')); - - if (excessLabels.length) { - core.warning( - `Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join( - ', ' - )}`, - {title: 'Label limit for a PR exceeded'} - ); - } - } catch (error: any) { - if ( - error.name === 'HttpError' && - error.message === 'Resource not accessible by integration' - ) { - core.warning( - `The action requires write permission to add labels to pull requests. For more information please refer to the action documentation: https://github.com/actions/labeler#permissions`, - { - title: `${process.env['GITHUB_ACTION_REPOSITORY']} running under '${github.context.eventName}' is misconfigured` - } - ); - core.setFailed(error.message); - } else { - throw error; + const labelsToAdd = [...allLabels].slice(0, GITHUB_MAX_LABELS); + const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); + + try { + let newLabels: string[] = []; + + if (!isListEqual(labelsToAdd, preexistingLabels)) { + await setLabels(client, prNumber, labelsToAdd); + newLabels = labelsToAdd.filter(l => !preexistingLabels.includes(l)); + } + + core.setOutput('new-labels', newLabels.join(',')); + core.setOutput('all-labels', labelsToAdd.join(',')); + + if (excessLabels.length) { + core.warning( + `Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join( + ', ' + )}`, + {title: 'Label limit for a PR exceeded'} + ); + } + } catch (error: any) { + if ( + error.name === 'HttpError' && + error.message === 'Resource not accessible by integration' + ) { + core.warning( + `The action requires write permission to add labels to pull requests. For more information please refer to the action documentation: https://github.com/actions/labeler#permissions`, + { + title: `${process.env['GITHUB_ACTION_REPOSITORY']} running under '${github.context.eventName}' is misconfigured` + } + ); + core.setFailed(error.message); + } else { + throw error; + } } } } catch (error: any) { @@ -99,13 +107,29 @@ export async function run() { } } -function getPrNumber(): number | undefined { +function getPrNumbers(): number[] { + const pullRequestNumbers = core.getMultilineInput('pr-number'); + if (pullRequestNumbers && pullRequestNumbers.length) { + const prNumbers: number[] = []; + + for (const prNumber of pullRequestNumbers) { + const prNumberInt = parseInt(prNumber, 10); + if (isNaN(prNumberInt) || prNumberInt <= 0) { + core.warning(`'${prNumber}' is not a valid pull request number`); + } else { + prNumbers.push(prNumberInt); + } + } + + return prNumbers; + } + const pullRequest = github.context.payload.pull_request; if (!pullRequest) { - return undefined; + return []; } - return pullRequest.number; + return [pullRequest.number]; } async function getChangedFiles(