Merge pull request #691 from akv-platform/v4-general-refactoring

Labeler general refactoring (ver. 4)
This commit is contained in:
Nikolai Laevskii
2023-10-09 07:12:12 +02:00
committed by GitHub
17 changed files with 2859 additions and 400 deletions

58
.licenses/npm/lodash.isequal.dep.yml generated Normal file
View File

@@ -0,0 +1,58 @@
---
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 <https://js.foundation/>
Based on Underscore.js, copyright Jeremy Ashkenas,
DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
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: []

2644
dist/index.js vendored

File diff suppressed because it is too large Load Diff

42
package-lock.json generated
View File

@@ -13,11 +13,13 @@
"@actions/github": "^5.1.1",
"@octokit/plugin-retry": "^5.0.5",
"js-yaml": "^4.1.0",
"lodash.isequal": "^4.5.0",
"minimatch": "^9.0.3"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/js-yaml": "^4.0.6",
"@types/lodash.isequal": "^4.5.6",
"@types/minimatch": "^5.1.2",
"@types/node": "^16.11.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
@@ -1429,6 +1431,21 @@
"integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.14.199",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz",
"integrity": "sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==",
"dev": true
},
"node_modules/@types/lodash.isequal": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz",
"integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==",
"dev": true,
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/minimatch": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
@@ -4829,6 +4846,11 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"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",
@@ -7403,6 +7425,21 @@
"integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
"dev": true
},
"@types/lodash": {
"version": "4.14.199",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz",
"integrity": "sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==",
"dev": true
},
"@types/lodash.isequal": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz",
"integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/minimatch": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
@@ -9882,6 +9919,11 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",

View File

@@ -28,11 +28,13 @@
"@actions/github": "^5.1.1",
"@octokit/plugin-retry": "^5.0.5",
"js-yaml": "^4.1.0",
"lodash.isequal": "^4.5.0",
"minimatch": "^9.0.3"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/js-yaml": "^4.0.6",
"@types/lodash.isequal": "^4.5.6",
"@types/minimatch": "^5.1.2",
"@types/node": "^16.11.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",

View File

@@ -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<string[]> => {
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;
};

View File

@@ -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* getPullRequests(
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
};
}
}

16
src/api/get-content.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as github from '@actions/github';
import {ClientType} from './types';
export const getContent = async (
client: ClientType,
repoPath: string
): Promise<string> => {
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();
};

View File

@@ -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<Map<string, StringOrMatchConfig[]>> =>
Promise.resolve()
.then(() => {
if (!fs.existsSync(configurationPath)) {
core.info(
`The configuration file (path: ${configurationPath}) was not found locally, fetching via the api`
);
return getContent(client, configurationPath);
}
core.info(
`The configuration file (path: ${configurationPath}) was 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<string,StringOrMatchConfig[]>` or throw if yaml is malformed:
return getLabelGlobMapFromObject(configObject);
});
function getLabelGlobMapFromObject(
configObject: any
): Map<string, StringOrMatchConfig[]> {
const labelGlobs: Map<string, StringOrMatchConfig[]> = 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;
}

6
src/api/index.ts Normal file
View File

@@ -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';

15
src/api/set-labels.ts Normal file
View File

@@ -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
});
};

2
src/api/types.ts Normal file
View File

@@ -0,0 +1,2 @@
import * as github from '@actions/github';
export type ClientType = ReturnType<typeof github.getOctokit>;

View File

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

View File

@@ -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;
};

1
src/get-inputs/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './get-inputs';

View File

@@ -1,9 +1,11 @@
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 isEqual from 'lodash.isequal';
import {printPattern} from './utils';
import {getInputs} from './get-inputs';
interface MatchConfig {
all?: string[];
@@ -16,217 +18,83 @@ type ClientType = ReturnType<typeof github.getOctokit>;
// 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);
const pullRequests = api.getPullRequests(client, prNumbers);
for await (const pullRequest of pullRequests) {
const labelGlobs: Map<string, StringOrMatchConfig[]> =
await api.getLabelGlobs(client, configPath);
const preexistingLabels = pullRequest.data.labels.map(l => l.name);
const allLabels: Set<string> = new Set<string>(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 (!isEqual(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<string, StringOrMatchConfig[]> =
await getLabelGlobs(client, configPath);
const preexistingLabels = pullRequest.labels.map(l => l.name);
const allLabels: Set<string> = new Set<string>(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<string[]> {
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<Map<string, StringOrMatchConfig[]>> {
let configurationContent: string;
try {
if (!fs.existsSync(configurationPath)) {
core.info(
`The configuration file (path: ${configurationPath}) was not found locally, fetching via the api`
);
configurationContent = await fetchContent(client, configurationPath);
} else {
core.info(
`The configuration file (path: ${configurationPath}) was 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<string,StringOrMatchConfig[]>` or throw if yaml is malformed:
return getLabelGlobMapFromObject(configObject);
}
async function fetchContent(
client: ClientType,
repoPath: string
): Promise<string> {
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<string, StringOrMatchConfig[]> {
const labelGlobs: Map<string, StringOrMatchConfig[]> = 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
});
}

1
src/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './print-pattern';

View File

@@ -0,0 +1,5 @@
import {Minimatch} from 'minimatch';
export const printPattern = (matcher: Minimatch): string => {
return (matcher.negate ? '!' : '') + matcher.pattern;
};