From 3b7f5051491ca59f60ccedb27e52fc15a51cc8b4 Mon Sep 17 00:00:00 2001 From: Nikolai Laevskii Date: Wed, 2 Aug 2023 06:13:14 +0200 Subject: [PATCH] General refactoring --- src/api/get-changed-files.ts | 24 +++ src/api/get-changed-pull-requests.ts | 38 ++++ src/api/get-content.ts | 16 ++ src/api/get-label-globs.ts | 70 +++++++ src/api/index.ts | 6 + src/api/set-labels.ts | 15 ++ src/api/types.ts | 2 + src/get-inputs/get-inputs.ts | 10 + src/get-inputs/get-pr-numbers.ts | 28 +++ src/get-inputs/index.ts | 1 + src/labeler.ts | 297 +++++++-------------------- src/utils/index.ts | 2 + src/utils/is-list-equal.ts | 3 + src/utils/print-pattern.ts | 5 + 14 files changed, 292 insertions(+), 225 deletions(-) create mode 100644 src/api/get-changed-files.ts create mode 100644 src/api/get-changed-pull-requests.ts create mode 100644 src/api/get-content.ts create mode 100644 src/api/get-label-globs.ts create mode 100644 src/api/index.ts create mode 100644 src/api/set-labels.ts create mode 100644 src/api/types.ts create mode 100644 src/get-inputs/get-inputs.ts create mode 100644 src/get-inputs/get-pr-numbers.ts create mode 100644 src/get-inputs/index.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/is-list-equal.ts create mode 100644 src/utils/print-pattern.ts diff --git a/src/api/get-changed-files.ts b/src/api/get-changed-files.ts new file mode 100644 index 00000000..6454f4db --- /dev/null +++ b/src/api/get-changed-files.ts @@ -0,0 +1,24 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import {ClientType} from './types'; + +export const getChangedFiles = async ( + client: ClientType, + prNumber: number +): Promise => { + const listFilesOptions = client.rest.pulls.listFiles.endpoint.merge({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + + const listFilesResponse = await client.paginate(listFilesOptions); + const changedFiles = listFilesResponse.map((f: any) => f.filename); + + core.debug('found changed files:'); + for (const file of changedFiles) { + core.debug(' ' + file); + } + + return changedFiles; +}; diff --git a/src/api/get-changed-pull-requests.ts b/src/api/get-changed-pull-requests.ts new file mode 100644 index 00000000..0dd3eec1 --- /dev/null +++ b/src/api/get-changed-pull-requests.ts @@ -0,0 +1,38 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import {getChangedFiles} from './get-changed-files'; +import {ClientType} from './types'; + +export async function* getChangedPullRequests( + client: ClientType, + prNumbers: number[] +) { + for (const prNumber of prNumbers) { + core.debug(`looking for pr #${prNumber}`); + let prData: any; + try { + const result = await client.rest.pulls.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + prData = result.data; + } catch (error: any) { + core.warning(`Could not find pull request #${prNumber}, skipping`); + continue; + } + + core.debug(`fetching changed files for pr #${prNumber}`); + const changedFiles: string[] = await getChangedFiles(client, prNumber); + if (!changedFiles.length) { + core.warning(`Pull request #${prNumber} has no changed files, skipping`); + continue; + } + + yield { + data: prData, + number: prNumber, + changedFiles + }; + } +} diff --git a/src/api/get-content.ts b/src/api/get-content.ts new file mode 100644 index 00000000..6743a90a --- /dev/null +++ b/src/api/get-content.ts @@ -0,0 +1,16 @@ +import * as github from '@actions/github'; +import {ClientType} from './types'; + +export const getContent = async ( + client: ClientType, + repoPath: string +): Promise => { + const response: any = await client.rest.repos.getContent({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + path: repoPath, + ref: github.context.sha + }); + + return Buffer.from(response.data.content, response.data.encoding).toString(); +}; diff --git a/src/api/get-label-globs.ts b/src/api/get-label-globs.ts new file mode 100644 index 00000000..aafc46a7 --- /dev/null +++ b/src/api/get-label-globs.ts @@ -0,0 +1,70 @@ +import * as core from '@actions/core'; +import * as yaml from 'js-yaml'; +import fs from 'fs'; +import {ClientType} from './types'; +import {getContent} from './get-content'; + +export interface MatchConfig { + all?: string[]; + any?: string[]; +} + +export type StringOrMatchConfig = string | MatchConfig; + +export const getLabelGlobs = ( + client: ClientType, + configurationPath: string +): Promise> => + Promise.resolve() + .then(() => { + if (!fs.existsSync(configurationPath)) { + core.info( + `The configuration file (path: ${configurationPath}) isn't not found locally, fetching via the api` + ); + + return getContent(client, configurationPath); + } + + core.info( + `The configuration file (path: ${configurationPath}) is found locally, reading from the file` + ); + + return fs.readFileSync(configurationPath, { + encoding: 'utf8' + }); + }) + .catch(error => { + if (error.name == 'HttpError' || error.name == 'NotFound') { + core.warning( + `The config file was not found at ${configurationPath}. Make sure it exists and that this action has the correct access rights.` + ); + } + return Promise.reject(error); + }) + .then(configuration => { + // loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`: + const configObject: any = yaml.load(configuration); + + // transform `any` => `Map` or throw if yaml is malformed: + return getLabelGlobMapFromObject(configObject); + }); + +function getLabelGlobMapFromObject( + configObject: any +): Map { + const labelGlobs: Map = new Map(); + + for (const label in configObject) { + if (typeof configObject[label] === 'string') { + labelGlobs.set(label, [configObject[label]]); + } else if (configObject[label] instanceof Array) { + labelGlobs.set(label, configObject[label]); + } else { + throw Error( + `found unexpected type for label ${label} (should be string or array of globs)` + ); + } + } + + return labelGlobs; +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 00000000..ebfda9ff --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,6 @@ +export * from './get-changed-files'; +export * from './get-changed-pull-requests'; +export * from './get-content'; +export * from './get-label-globs'; +export * from './set-labels'; +export * from './types'; diff --git a/src/api/set-labels.ts b/src/api/set-labels.ts new file mode 100644 index 00000000..6d598535 --- /dev/null +++ b/src/api/set-labels.ts @@ -0,0 +1,15 @@ +import * as github from '@actions/github'; +import {ClientType} from './types'; + +export const setLabels = async ( + client: ClientType, + prNumber: number, + labels: string[] +) => { + await client.rest.issues.setLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + labels: labels + }); +}; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 00000000..03af2dfe --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,2 @@ +import * as github from '@actions/github'; +export type ClientType = ReturnType; diff --git a/src/get-inputs/get-inputs.ts b/src/get-inputs/get-inputs.ts new file mode 100644 index 00000000..48212498 --- /dev/null +++ b/src/get-inputs/get-inputs.ts @@ -0,0 +1,10 @@ +import * as core from '@actions/core'; +import {getPrNumbers} from './get-pr-numbers'; + +export const getInputs = () => ({ + token: core.getInput('repo-token'), + configPath: core.getInput('configuration-path', {required: true}), + syncLabels: !!core.getInput('sync-labels'), + dot: core.getBooleanInput('dot'), + prNumbers: getPrNumbers() +}); diff --git a/src/get-inputs/get-pr-numbers.ts b/src/get-inputs/get-pr-numbers.ts new file mode 100644 index 00000000..35324ce5 --- /dev/null +++ b/src/get-inputs/get-pr-numbers.ts @@ -0,0 +1,28 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +const getPrNumberFromContext = () => + github.context.payload.pull_request?.number; + +export const getPrNumbers = (): number[] => { + const prInput = core.getMultilineInput('pr-number'); + + if (!prInput?.length) { + return [getPrNumberFromContext()].filter(Boolean) as number[]; + } + + const result: number[] = []; + + for (const line of prInput) { + const prNumber = parseInt(line, 10); + + if (isNaN(prNumber) && prNumber <= 0) { + core.warning(`'${prNumber}' is not a valid pull request number`); + continue; + } + + result.push(prNumber); + } + + return result; +}; diff --git a/src/get-inputs/index.ts b/src/get-inputs/index.ts new file mode 100644 index 00000000..d1705bf1 --- /dev/null +++ b/src/get-inputs/index.ts @@ -0,0 +1 @@ +export * from './get-inputs'; diff --git a/src/labeler.ts b/src/labeler.ts index 995abaa3..4988ccf7 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -1,9 +1,10 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; import * as pluginRetry from '@octokit/plugin-retry'; -import * as yaml from 'js-yaml'; -import fs from 'fs'; import {Minimatch} from 'minimatch'; +import * as api from './api'; +import {isListEqual, printPattern} from './utils'; +import {getInputs} from './get-inputs'; interface MatchConfig { all?: string[]; @@ -16,217 +17,84 @@ type ClientType = ReturnType; // GitHub Issues cannot have more than 100 labels const GITHUB_MAX_LABELS = 100; -export async function run() { - try { - const token = core.getInput('repo-token'); - const configPath = core.getInput('configuration-path', {required: true}); - const syncLabels = !!core.getInput('sync-labels'); - const dot = core.getBooleanInput('dot'); +export const run = () => + labeler().catch(error => { + core.error(error); + core.setFailed(error.message); + }); + +async function labeler() { + const {token, configPath, syncLabels, dot, prNumbers} = getInputs(); + + if (!prNumbers.length) { + core.warning('Could not get pull request number(s), exiting'); + return; + } + + const client: ClientType = github.getOctokit(token, {}, pluginRetry.retry); + + for await (const pullRequest of api.getChangedPullRequests( + client, + prNumbers + )) { + const labelGlobs: Map = + await api.getLabelGlobs(client, configPath); + const preexistingLabels = pullRequest.data.labels.map(l => l.name); + const allLabels: Set = new Set(preexistingLabels); + + for (const [label, globs] of labelGlobs.entries()) { + core.debug(`processing ${label}`); + if (checkGlobs(pullRequest.changedFiles, globs, dot)) { + allLabels.add(label); + } else if (syncLabels) { + allLabels.delete(label); + } + } + + const labelsToAdd = [...allLabels].slice(0, GITHUB_MAX_LABELS); + const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS); + + let newLabels: string[] = []; + + try { + if (!isListEqual(labelsToAdd, preexistingLabels)) { + await api.setLabels(client, pullRequest.number, labelsToAdd); + newLabels = labelsToAdd.filter( + label => !preexistingLabels.includes(label) + ); + } + } catch (error: any) { + if ( + error.name !== 'HttpError' || + error.message !== 'Resource not accessible by integration' + ) { + throw error; + } + + 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 prNumbers = getPrNumbers(); - if (!prNumbers.length) { - core.warning('Could not get pull request number(s), exiting'); return; } - const client: ClientType = github.getOctokit(token, {}, pluginRetry.retry); + core.setOutput('new-labels', newLabels.join(',')); + core.setOutput('all-labels', labelsToAdd.join(',')); - 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; - } - - core.debug(`fetching changed files for pr #${prNumber}`); - const changedFiles: string[] = await getChangedFiles(client, prNumber); - if (!changedFiles.length) { - core.warning( - `Pull request #${prNumber} has no changed files, skipping` - ); - continue; - } - - 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); - } - } - - 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) { - core.error(error); - core.setFailed(error.message); - } -} - -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 []; - } - - return [pullRequest.number]; -} - -async function getChangedFiles( - client: ClientType, - prNumber: number -): Promise { - const listFilesOptions = client.rest.pulls.listFiles.endpoint.merge({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - pull_number: prNumber - }); - - const listFilesResponse = await client.paginate(listFilesOptions); - const changedFiles = listFilesResponse.map((f: any) => f.filename); - - core.debug('found changed files:'); - for (const file of changedFiles) { - core.debug(' ' + file); - } - - return changedFiles; -} - -async function getLabelGlobs( - client: ClientType, - configurationPath: string -): Promise> { - let configurationContent: string; - try { - if (!fs.existsSync(configurationPath)) { - core.info( - `The configuration file (path: ${configurationPath}) isn't not found locally, fetching via the api` - ); - configurationContent = await fetchContent(client, configurationPath); - } else { - core.info( - `The configuration file (path: ${configurationPath}) is found locally, reading from the file` - ); - configurationContent = fs.readFileSync(configurationPath, { - encoding: 'utf8' - }); - } - } catch (e: any) { - if (e.name == 'HttpError' || e.name == 'NotFound') { + if (excessLabels.length) { core.warning( - `The config file was not found at ${configurationPath}. Make sure it exists and that this action has the correct access rights.` - ); - } - throw e; - } - - // loads (hopefully) a `{[label:string]: string | StringOrMatchConfig[]}`, but is `any`: - const configObject: any = yaml.load(configurationContent); - - // transform `any` => `Map` or throw if yaml is malformed: - return getLabelGlobMapFromObject(configObject); -} - -async function fetchContent( - client: ClientType, - repoPath: string -): Promise { - const response: any = await client.rest.repos.getContent({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - path: repoPath, - ref: github.context.sha - }); - - return Buffer.from(response.data.content, response.data.encoding).toString(); -} - -function getLabelGlobMapFromObject( - configObject: any -): Map { - const labelGlobs: Map = new Map(); - for (const label in configObject) { - if (typeof configObject[label] === 'string') { - labelGlobs.set(label, [configObject[label]]); - } else if (configObject[label] instanceof Array) { - labelGlobs.set(label, configObject[label]); - } else { - throw Error( - `found unexpected type for label ${label} (should be string or array of globs)` + `Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join( + ', ' + )}`, + {title: 'Label limit for a PR exceeded'} ); } } - - return labelGlobs; } function toMatchConfig(config: StringOrMatchConfig): MatchConfig { @@ -239,10 +107,6 @@ function toMatchConfig(config: StringOrMatchConfig): MatchConfig { return config; } -function printPattern(matcher: Minimatch): string { - return (matcher.negate ? '!' : '') + matcher.pattern; -} - export function checkGlobs( changedFiles: string[], globs: StringOrMatchConfig[], @@ -329,20 +193,3 @@ function checkMatch( return true; } - -function isListEqual(listA: string[], listB: string[]): boolean { - return listA.length === listB.length && listA.every(el => listB.includes(el)); -} - -async function setLabels( - client: ClientType, - prNumber: number, - labels: string[] -) { - await client.rest.issues.setLabels({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: prNumber, - labels: labels - }); -} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..23b6eb7b --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './is-list-equal'; +export * from './print-pattern'; diff --git a/src/utils/is-list-equal.ts b/src/utils/is-list-equal.ts new file mode 100644 index 00000000..a5584d07 --- /dev/null +++ b/src/utils/is-list-equal.ts @@ -0,0 +1,3 @@ +export const isListEqual = (listA: string[], listB: string[]): boolean => { + return listA.length === listB.length && listA.every(el => listB.includes(el)); +}; diff --git a/src/utils/print-pattern.ts b/src/utils/print-pattern.ts new file mode 100644 index 00000000..ab497530 --- /dev/null +++ b/src/utils/print-pattern.ts @@ -0,0 +1,5 @@ +import {Minimatch} from 'minimatch'; + +export const printPattern = (matcher: Minimatch): string => { + return (matcher.negate ? '!' : '') + matcher.pattern; +};