1
0
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:
Luca Boccassi
2026-03-26 15:03:12 +00:00
committed by GitHub
parent e52e4fb63e
commit c5dadc2a45
14 changed files with 1129 additions and 25 deletions

View File

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

View File

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