diff --git a/.github/actions/labeler/action.yml b/.github/actions/labeler/action.yml new file mode 100644 index 0000000000..67c7a1fedc --- /dev/null +++ b/.github/actions/labeler/action.yml @@ -0,0 +1,33 @@ +name: Labeler + +description: Adds labels to an Issue or PR +inputs: + token: + description: defaults to GITHUB_TOKEN, otherwise can use a PAT + required: false + default: ${{ github.token }} + addLabels: + description: array of labels to apply + required: false + removeLabels: + description: array of labels to remove + required: false + ignoreIfAssigned: + description: don't apply labels if there are assignees + required: false + ignoreIfLabeled: + description: don't apply labels if there are already labels added + required: false + +runs: + using: 'composite' + steps: + - name: Add label to an issue or pr + run: node .github/actions/labeler/labeler.js + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + ADD_LABELS: ${{ inputs.addLabels }} + REMOVE_LABELS: ${{ inputs.removeLabels }} + IGNORE_IF_ASSIGNED: ${{ inputs.ignoreIfAssigned }} + IGNORE_IF_LABELED: ${{ inputs.ignoreIfLabeled }} diff --git a/.github/actions/labeler/labeler.js b/.github/actions/labeler/labeler.js new file mode 100644 index 0000000000..49f2a9e8cf --- /dev/null +++ b/.github/actions/labeler/labeler.js @@ -0,0 +1,160 @@ +/* See function main in this file for documentation */ + +import coreLib from '@actions/core' + +import github from '#src/workflows/github.js' +import { getActionContext } from '#src/workflows/action-context.js' +import { boolEnvVar } from '#src/workflows/get-env-inputs.js' + +// When this file is invoked directly from action as opposed to being imported +if (import.meta.url.endsWith(process.argv[1])) { + if (!process.env.GITHUB_TOKEN) { + throw new Error('You must set the GITHUB_TOKEN environment variable.') + } + + const { ADD_LABELS, REMOVE_LABELS } = process.env + + const octokit = github() + + const opts = { + addLabels: ADD_LABELS, + removeLabels: REMOVE_LABELS, + ignoreIfAssigned: boolEnvVar('IGNORE_IF_ASSIGNED'), + ignoreIfLabeled: boolEnvVar('IGNORE_IF_LABELED'), + } + + // labels come in comma separated from actions + let addLabels + + if (opts.addLabels) { + addLabels = [...opts.addLabels.split(',')] + opts.addLabels = addLabels.map((l) => l.trim()) + } else { + opts.addLabels = [] + } + + let removeLabels + + if (opts.removeLabels) { + removeLabels = [...opts.removeLabels.split(',')] + opts.removeLabels = removeLabels.map((l) => l.trim()) + } else { + opts.removeLabels = [] + } + + const actionContext = getActionContext() + const { owner, repo } = actionContext + let issueOrPrNumber = actionContext?.pull_request?.number + + if (!issueOrPrNumber) { + issueOrPrNumber = actionContext?.issue?.number + } + + opts.issue_number = issueOrPrNumber + opts.owner = owner + opts.repo = repo + + main(coreLib, octokit, opts, {}) +} + +/* + * Applies labels to an issue or pull request. + * + * opts: + * issue_number {number} id of the issue or pull request to label + * owner {string} owner of the repository + * repo {string} repository name + * addLabels {Array} array of labels to apply + * removeLabels {Array} array of labels to remove + * ignoreIfAssigned {boolean} don't apply labels if there are assignees + * ignoreIfLabeled {boolean} don't apply labels if there are already labels added + */ +export default async function main(core, octokit, opts = {}) { + if (opts.addLabels?.length === 0 && opts.removeLabels?.length === 0) { + core.info('No labels to add or remove specified, nothing to do.') + return + } + + if (opts.ignoreIfAssigned || opts.ignoreIfLabeled) { + try { + const { data } = await octokit.issues.get({ + issue_number: opts.issue_number, + owner: opts.owner, + repo: opts.repo, + }) + + if (opts.ignoreIfAssigned) { + if (data.assignees.length > 0) { + core.info( + `ignore-if-assigned is true: not applying labels since there's ${data.assignees.length} assignees`, + ) + return 0 + } + } + + if (opts.ignoreIfLabeled) { + if (data.labels.length > 0) { + core.info( + `ignore-if-labeled is true: not applying labels since there's ${data.labels.length} labels applied`, + ) + return 0 + } + } + } catch (err) { + throw new Error(`Error getting issue: ${err}`) + } + } + + if (opts.removeLabels?.length > 0) { + // removing a label fails if the label isn't already applied + let appliedLabels = [] + + try { + const { data } = await octokit.issues.get({ + issue_number: opts.issue_number, + owner: opts.owner, + repo: opts.repo, + }) + + appliedLabels = data.labels.map((l) => l.name) + } catch (err) { + throw new Error(`Error getting issue: ${err}`) + } + + opts.removeLabels = opts.removeLabels.filter((l) => appliedLabels.includes(l)) + + await Promise.all( + opts.removeLabels.map(async (label) => { + try { + await octokit.issues.removeLabel({ + issue_number: opts.issue_number, + owner: opts.owner, + repo: opts.repo, + name: label, + }) + } catch (err) { + throw new Error(`Error removing label: ${err}`) + } + }), + ) + + if (opts.removeLabels.length > 0) { + core.info(`Removed labels: ${opts.removeLabels.join(', ')}`) + } + } + + if (opts.addLabels?.length > 0) { + try { + await octokit.issues.addLabels({ + issue_number: opts.issue_number, + owner: opts.owner, + repo: opts.repo, + labels: opts.addLabels, + }) + + core.info(`Added labels: ${opts.addLabels.join(', ')}`) + } catch (err) { + throw new Error(`Error adding label: ${err}`) + } + } +} diff --git a/src/workflows/labeler.ts b/src/workflows/labeler.ts new file mode 100755 index 0000000000..622640d573 --- /dev/null +++ b/src/workflows/labeler.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +// [start-readme] +// +// This script adds labels to issues or pull requests. +// +// [end-readme] + +import { program } from 'commander' +import label from '../../.github/actions/labeler/labeler.js' +import { getCoreInject } from '@/links/scripts/action-injections' +import github from '@/workflows/github.js' + +program + .description('Add labels to an issue or PR.') + .option('--id ', 'Id of the issue or PR to label') + .option('--add-labels ', 'Labels to add, comma separated if more than one') + .option('--remove-labels ', 'Labels to remove, comma separated if more than one') + .option('--repo ', 'Which repository to apply labels to in owner/repo format') + .option('--ignore-if-assigned', "Don't apply labels if there are assignees") + .option('--ignore-if-labeled', "Don't apply labels if there are already labels applied") + .parse(process.argv) + +const opts = program.opts() +const octokit = github() + +let addLabels = [] +if (opts.addLabels) { + addLabels = [...opts.addLabels.split(',')] + addLabels = addLabels.map((l) => l.trim()) +} + +let removeLabels = [] +if (opts.removeLabels) { + removeLabels = [...opts.removeLabels.split(',')] + removeLabels = removeLabels.map((l) => l.trim()) +} + +if (!process.env.GITHUB_TOKEN) { + throw new Error('You must set the GITHUB_TOKEN environment variable.') +} + +if (!opts.repo) { + throw new Error('You must provide the repository that contains the issue with the --repo flag.') +} + +if (!opts.id) { + throw new Error( + 'You must provide the number of the issue where the labels will be applied with the --id flag', + ) +} + +const [owner, repo] = opts.repo.split('/') +const issueNumber = opts.id + +label(getCoreInject(opts.debug), octokit, { + addLabels, + removeLabels, + repo, + owner, + issue_number: issueNumber, + ignoreIfAssigned: opts.ignoreIfAssigned, + ignoreIfLabeled: opts.ignoreIfLabeled, +})