mirror of
https://github.com/actions/labeler
synced 2026-05-07 12:41:02 +02:00
Add 'changed-files-labels-limit' and 'max-files-changed' configs to allow capping number of labels added (#923)
* README.md: drop trailing whitespace * Add 'changed-files-labels-limit' config to allow capping number of labels added When a repository has many components, each with a changed-files label, a large refactor ends up with the labeler spamming the pull request with label changes. The end result is not very useful as it's not very readable, and due to how github automatically hides comments when label changes overflow the discussion tab, it means useful information is hidden and one has to manually click "Load more..." dozens of time every time the page is loaded. Add a changed-files-labels-limit top level config knob. If more than the configured limit of labels is set to be added, none are added. This only affects changed-files labels. * Add 'max-files-changed' config to allow capping number of files for labelling When a PR modifies a very large number of files (e.g., tree-wide refactors, automated code formatting), this new options allows skipping file-based labeling entirely when the number of files that are changed hits the configured limit. Fixes https://github.com/actions/labeler/issues/486
This commit is contained in:
@@ -18,12 +18,53 @@ export interface MatchConfig {
|
||||
|
||||
export type BaseMatchConfig = BranchMatchConfig & ChangedFilesMatchConfig;
|
||||
|
||||
export interface LabelConfigResult {
|
||||
labelConfigs: Map<string, MatchConfig[]>;
|
||||
changedFilesLimit?: number;
|
||||
maxFilesChanged?: number;
|
||||
}
|
||||
|
||||
const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch'];
|
||||
const TOP_LEVEL_OPTIONS = ['changed-files-labels-limit', 'max-files-changed'];
|
||||
|
||||
/**
|
||||
* Parses and validates a non-negative integer value from the configuration.
|
||||
*/
|
||||
function parseNonNegativeInteger(value: unknown, optionName: string): number {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
||||
throw new Error(
|
||||
`Invalid value for '${optionName}': must be a non-negative integer (got ${value})`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Invalid value for '${optionName}': must be a non-negative integer (got '${value}')`
|
||||
);
|
||||
}
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error(
|
||||
`'${optionName}' is a reserved top-level option and cannot be used as a label name. Please rename it.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid value for '${optionName}': expected a non-negative integer`
|
||||
);
|
||||
}
|
||||
|
||||
export const getLabelConfigs = (
|
||||
client: ClientType,
|
||||
configurationPath: string
|
||||
): Promise<Map<string, MatchConfig[]>> =>
|
||||
): Promise<LabelConfigResult> =>
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (!fs.existsSync(configurationPath)) {
|
||||
@@ -54,15 +95,49 @@ export const getLabelConfigs = (
|
||||
// loads (hopefully) a `{[label:string]: MatchConfig[]}`, but is `any`:
|
||||
const configObject: any = yaml.load(configuration);
|
||||
|
||||
// transform `any` => `Map<string,MatchConfig[]>` or throw if yaml is malformed:
|
||||
return getLabelConfigMapFromObject(configObject);
|
||||
// transform `any` => `LabelConfigResult` or throw if yaml is malformed:
|
||||
return getLabelConfigResultFromObject(configObject);
|
||||
});
|
||||
|
||||
export function getLabelConfigResultFromObject(
|
||||
configObject: any
|
||||
): LabelConfigResult {
|
||||
// Extract top-level options
|
||||
let changedFilesLimit: number | undefined;
|
||||
let maxFilesChanged: number | undefined;
|
||||
|
||||
const limitValue = configObject?.['changed-files-labels-limit'];
|
||||
if (limitValue !== undefined) {
|
||||
changedFilesLimit = parseNonNegativeInteger(
|
||||
limitValue,
|
||||
'changed-files-labels-limit'
|
||||
);
|
||||
}
|
||||
|
||||
const maxFilesValue = configObject?.['max-files-changed'];
|
||||
if (maxFilesValue !== undefined) {
|
||||
maxFilesChanged = parseNonNegativeInteger(
|
||||
maxFilesValue,
|
||||
'max-files-changed'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
labelConfigs: getLabelConfigMapFromObject(configObject),
|
||||
changedFilesLimit,
|
||||
maxFilesChanged
|
||||
};
|
||||
}
|
||||
|
||||
export function getLabelConfigMapFromObject(
|
||||
configObject: any
|
||||
): Map<string, MatchConfig[]> {
|
||||
const labelMap: Map<string, MatchConfig[]> = new Map();
|
||||
for (const label in configObject) {
|
||||
// Skip top-level options
|
||||
if (TOP_LEVEL_OPTIONS.includes(label)) {
|
||||
continue;
|
||||
}
|
||||
const configOptions = configObject[label];
|
||||
if (
|
||||
!Array.isArray(configOptions) ||
|
||||
@@ -124,3 +199,33 @@ export function toMatchConfig(config: any): BaseMatchConfig {
|
||||
...branchConfig
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the match configs for a label use changed-files patterns.
|
||||
* This is used to determine if a label should be counted toward the changed-files limit.
|
||||
*/
|
||||
export function configUsesChangedFiles(matchConfigs: MatchConfig[]): boolean {
|
||||
for (const config of matchConfigs) {
|
||||
if (config.all) {
|
||||
for (const baseConfig of config.all) {
|
||||
if (
|
||||
baseConfig.changedFiles &&
|
||||
baseConfig.changedFiles.some(cf => Object.keys(cf).length > 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.any) {
|
||||
for (const baseConfig of config.any) {
|
||||
if (
|
||||
baseConfig.changedFiles &&
|
||||
baseConfig.changedFiles.some(cf => Object.keys(cf).length > 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import * as api from './api';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import {getInputs} from './get-inputs';
|
||||
|
||||
import {BaseMatchConfig, MatchConfig} from './api/get-label-configs';
|
||||
import {
|
||||
BaseMatchConfig,
|
||||
MatchConfig,
|
||||
configUsesChangedFiles
|
||||
} from './api/get-label-configs';
|
||||
|
||||
import {checkAllChangedFiles, checkAnyChangedFiles} from './changedFiles';
|
||||
|
||||
@@ -35,22 +39,68 @@ export async function labeler() {
|
||||
const pullRequests = api.getPullRequests(client, prNumbers);
|
||||
|
||||
for await (const pullRequest of pullRequests) {
|
||||
const labelConfigs: Map<string, MatchConfig[]> = await api.getLabelConfigs(
|
||||
client,
|
||||
configPath
|
||||
);
|
||||
const {labelConfigs, changedFilesLimit, maxFilesChanged} =
|
||||
await api.getLabelConfigs(client, configPath);
|
||||
|
||||
// Check if total changed files exceeds the max-files-changed threshold
|
||||
const skipChangedFilesLabeling =
|
||||
maxFilesChanged !== undefined &&
|
||||
pullRequest.changedFiles.length > maxFilesChanged;
|
||||
|
||||
if (skipChangedFilesLabeling) {
|
||||
core.info(
|
||||
`Total changed files (${pullRequest.changedFiles.length}) exceeds max-files-changed (${maxFilesChanged}), skipping file-based labeling`
|
||||
);
|
||||
}
|
||||
|
||||
const preexistingLabels = pullRequest.data.labels.map(l => l.name);
|
||||
const allLabels: Set<string> = new Set<string>(preexistingLabels);
|
||||
|
||||
// Track labels that would be added based on changed-files patterns
|
||||
const changedFilesLabels: Set<string> = new Set<string>();
|
||||
|
||||
for (const [label, configs] of labelConfigs.entries()) {
|
||||
core.debug(`processing ${label}`);
|
||||
|
||||
// If this config uses changed-files and we're skipping file-based labeling,
|
||||
// don't evaluate it at all (skip add/remove) to preserve preexisting labels
|
||||
const usesChangedFiles = configUsesChangedFiles(configs);
|
||||
if (skipChangedFilesLabeling && usesChangedFiles) {
|
||||
core.debug(
|
||||
`skipping ${label} (uses changed-files and max-files-changed exceeded)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (checkMatchConfigs(pullRequest.changedFiles, configs, dot)) {
|
||||
allLabels.add(label);
|
||||
// Track if this label uses changed-files patterns
|
||||
if (usesChangedFiles) {
|
||||
changedFilesLabels.add(label);
|
||||
}
|
||||
} else if (syncLabels) {
|
||||
allLabels.delete(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if changed-files labels should be skipped due to labels limit
|
||||
const newChangedFilesLabels = [...changedFilesLabels].filter(
|
||||
l => !preexistingLabels.includes(l)
|
||||
);
|
||||
|
||||
if (
|
||||
changedFilesLimit !== undefined &&
|
||||
newChangedFilesLabels.length > changedFilesLimit
|
||||
) {
|
||||
core.info(
|
||||
`Changed-files labels (${newChangedFilesLabels.length}) exceed limit (${changedFilesLimit}), skipping: ${newChangedFilesLabels.join(', ')}`
|
||||
);
|
||||
// Remove all new changed-files labels
|
||||
for (const label of newChangedFilesLabels) {
|
||||
allLabels.delete(label);
|
||||
}
|
||||
}
|
||||
|
||||
const labelsToApply = [...allLabels].slice(0, GITHUB_MAX_LABELS);
|
||||
const excessLabels = [...allLabels].slice(GITHUB_MAX_LABELS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user