diff --git a/README.md b/README.md index f9d14a899..ae7d72bba 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The base match object is defined as: - all-globs-to-all-files: ['list', 'of', 'globs'] - base-branch: ['list', 'of', 'regexps'] - head-branch: ['list', 'of', 'regexps'] +- description: 'Description of label' +- color: 'Color of label' ``` There are two top-level keys, `any` and `all`, which both accept the same configuration options: @@ -79,6 +81,8 @@ The fields are defined as follows: - `any-glob-to-all-files`: ANY glob must match against ALL changed files - `all-globs-to-any-file`: ALL globs must match against ANY changed file - `all-globs-to-all-files`: ALL globs must match against ALL changed files +- `description`: The description of the label if creating it +- `color`: The color of the label if creating it (6-character hex, with or without `#`) If a base option is provided without a top-level key, then it will default to `any`. More specifically, the following two configurations are equivalent: ```yml @@ -151,6 +155,13 @@ feature: # Add 'release' label to any PR that is opened against the `main` branch release: - base-branch: 'main' + + # Define label properties + created-label: + - changed-files: + - any-glob-to-any-file: '*' + - description: "This label was created if it didn't already exist" + - color: "abcdef" ``` ### Create Workflow @@ -319,4 +330,4 @@ Once you confirm that the updated configuration files function as intended, you ## Contributions -Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md). \ No newline at end of file +Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md). diff --git a/__mocks__/@actions/github.ts b/__mocks__/@actions/github.ts index 5d6ecd56d..2144c0ddf 100644 --- a/__mocks__/@actions/github.ts +++ b/__mocks__/@actions/github.ts @@ -19,7 +19,16 @@ export const context = { const mockApi = { rest: { issues: { - setLabels: jest.fn() + setLabels: jest.fn(), + updateLabel: jest.fn(), + createLabel: jest.fn(), + listLabelsForRepo: { + endpoint: { + merge: jest.fn().mockReturnValue({ + __labelerMock: 'listLabelsForRepo' + }) + } + } }, pulls: { get: jest.fn().mockResolvedValue({ @@ -29,7 +38,9 @@ const mockApi = { }), listFiles: { endpoint: { - merge: jest.fn().mockReturnValue({}) + merge: jest.fn().mockReturnValue({ + __labelerMock: 'listFiles' + }) } } }, diff --git a/__tests__/fixtures/all_options.yml b/__tests__/fixtures/all_options.yml index 9417d13ce..700c37553 100644 --- a/__tests__/fixtures/all_options.yml +++ b/__tests__/fixtures/all_options.yml @@ -9,9 +9,13 @@ label1: - all-globs-to-all-files: ['glob'] - head-branch: ['regexp'] - base-branch: ['regexp'] + - description: 'Label1 description' + - color: 'ff00ff' label2: - changed-files: - any-glob-to-any-file: ['glob'] - head-branch: ['regexp'] - base-branch: ['regexp'] + - description: 'Label2 description' + - color: 'ffff00' diff --git a/__tests__/fixtures/label_meta.yml b/__tests__/fixtures/label_meta.yml new file mode 100644 index 000000000..105286f25 --- /dev/null +++ b/__tests__/fixtures/label_meta.yml @@ -0,0 +1,20 @@ +label1: + - any: + - changed-files: + - any-glob-to-any-file: ['tests/**/*'] + +label2: + - changed-files: + - any-glob-to-any-file: ['tests/**/*'] + - description: 'Label2 description' + - color: 'ff00ff' + +label3: + - changed-files: + - any-glob-to-any-file: ['tests/**/*'] + - description: 'Label3 description' + +label4: + - changed-files: + - any-glob-to-any-file: ['tests/**/*'] + - color: '#000000' diff --git a/__tests__/labeler.test.ts b/__tests__/labeler.test.ts index 75d25ef2f..41cf962b3 100644 --- a/__tests__/labeler.test.ts +++ b/__tests__/labeler.test.ts @@ -8,9 +8,11 @@ import {checkMatchConfigs} from '../src/labeler'; import { MatchConfig, toMatchConfig, + toLabelConfig, getLabelConfigMapFromObject, BaseMatchConfig } from '../src/api/get-label-configs'; +import {updateLabels} from '../src/api/set-labels'; jest.mock('@actions/core'); jest.mock('../src/api'); @@ -44,6 +46,12 @@ describe('getLabelConfigMapFromObject', () => { {baseBranch: undefined, headBranch: ['regexp']}, {baseBranch: ['regexp'], headBranch: undefined} ] + }, + { + meta: { + description: 'Label1 description', + color: 'ff00ff' + } } ]); expected.set('label2', [ @@ -53,6 +61,12 @@ describe('getLabelConfigMapFromObject', () => { {baseBranch: undefined, headBranch: ['regexp']}, {baseBranch: ['regexp'], headBranch: undefined} ] + }, + { + meta: { + description: 'Label2 description', + color: 'ffff00' + } } ]); @@ -91,6 +105,24 @@ describe('toMatchConfig', () => { }); }); +describe('toLabelConfig', () => { + it('normalizes color values and accepts # prefixes', () => { + const warningSpy = jest.spyOn(core, 'warning').mockImplementation(); + const result = toLabelConfig({color: '#ff00ff'}); + expect(result).toEqual({color: 'ff00ff'}); + expect(warningSpy).not.toHaveBeenCalled(); + warningSpy.mockRestore(); + }); + + it('warns and drops invalid color values', () => { + const warningSpy = jest.spyOn(core, 'warning').mockImplementation(); + const result = toLabelConfig({color: '#fff'}); + expect(result).toEqual({}); + expect(warningSpy).toHaveBeenCalledTimes(1); + warningSpy.mockRestore(); + }); +}); + describe('checkMatchConfigs', () => { describe('when a single match config is provided', () => { const matchConfig: MatchConfig[] = [ @@ -233,3 +265,81 @@ describe('labeler error handling', () => { expect(core.setFailed).toHaveBeenCalledWith(error.message); }); }); + +describe('updateLabels', () => { + const gh = github.getOctokit('_'); + const updateLabelMock = jest.spyOn(gh.rest.issues, 'updateLabel'); + const createLabelMock = jest.spyOn(gh.rest.issues, 'createLabel'); + const paginateMock = jest.spyOn(gh, 'paginate'); + + const buildLabelConfigs = ( + meta: MatchConfig['meta'] + ): Map => new Map([['label1', [{meta}]]]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates existing labels when metadata differs', async () => { + paginateMock.mockResolvedValue([ + {name: 'label1', color: '000000', description: 'old'} + ]); + + const labelConfigs = buildLabelConfigs({ + color: 'ff00ff', + description: 'new' + }); + const repoLabelCache = new Map(); + + await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache); + + expect(updateLabelMock).toHaveBeenCalledTimes(1); + expect(updateLabelMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + name: 'label1', + color: 'ff00ff', + description: 'new' + }); + expect(createLabelMock).toHaveBeenCalledTimes(0); + }); + + it('does not update labels when metadata matches', async () => { + paginateMock.mockResolvedValue([ + {name: 'label1', color: 'ff00ff', description: 'same'} + ]); + + const labelConfigs = buildLabelConfigs({ + color: 'ff00ff', + description: 'same' + }); + const repoLabelCache = new Map(); + + await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache); + + expect(updateLabelMock).toHaveBeenCalledTimes(0); + expect(createLabelMock).toHaveBeenCalledTimes(0); + }); + + it('creates labels when missing from the repository', async () => { + paginateMock.mockResolvedValue([]); + + const labelConfigs = buildLabelConfigs({ + color: 'ff00ff', + description: 'new' + }); + const repoLabelCache = new Map(); + + await updateLabels(gh, ['label1'], labelConfigs, repoLabelCache); + + expect(createLabelMock).toHaveBeenCalledTimes(1); + expect(createLabelMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + name: 'label1', + color: 'ff00ff', + description: 'new' + }); + expect(updateLabelMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 0490f7953..f66e397b9 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -9,6 +9,8 @@ jest.mock('@actions/github'); const gh = github.getOctokit('_'); const setLabelsMock = jest.spyOn(gh.rest.issues, 'setLabels'); +const updateLabelsMock = jest.spyOn(gh.rest.issues, 'updateLabel'); +const createLabelsMock = jest.spyOn(gh.rest.issues, 'createLabel'); const reposMock = jest.spyOn(gh.rest.repos, 'getContent'); const paginateMock = jest.spyOn(gh, 'paginate'); const getPullMock = jest.spyOn(gh.rest.pulls, 'get'); @@ -37,7 +39,8 @@ const yamlFixtures = { 'branches.yml': fs.readFileSync('__tests__/fixtures/branches.yml'), 'only_pdfs.yml': fs.readFileSync('__tests__/fixtures/only_pdfs.yml'), 'not_supported.yml': fs.readFileSync('__tests__/fixtures/not_supported.yml'), - 'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml') + 'any_and_all.yml': fs.readFileSync('__tests__/fixtures/any_and_all.yml'), + 'label_meta.yml': fs.readFileSync('__tests__/fixtures/label_meta.yml') }; const configureInput = ( @@ -471,6 +474,78 @@ describe('run', () => { expect(reposMock).toHaveBeenCalled(); }); + it('creates missing labels with metadata', async () => { + configureInput({ + 'repo-token': 'foo', + 'configuration-path': 'bar' + }); + + usingLabelerConfigYaml('label_meta.yml'); + mockGitHubResponseChangedFiles('tests/test.txt'); + mockGitHubResponseRepoLabels([]); + getPullMock.mockResolvedValue({ + data: { + labels: [] + } + }); + + await run(); + + expect(setLabelsMock).toHaveBeenCalledTimes(1); + expect(setLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + issue_number: 123, + labels: ['label1', 'label2', 'label3', 'label4'] + }); + + expect(createLabelsMock).toHaveBeenCalledTimes(3); + expect(createLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + name: 'label2', + color: 'ff00ff', + description: 'Label2 description' + }); + expect(createLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + name: 'label3', + description: 'Label3 description' + }); + expect(createLabelsMock).toHaveBeenCalledWith({ + owner: 'monalisa', + repo: 'helloworld', + name: 'label4', + color: '000000' + }); + expect(updateLabelsMock).toHaveBeenCalledTimes(0); + expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued + }); + + it('does not create labels or issue warnings if all labels exist', async () => { + configureInput({ + 'repo-token': 'foo', + 'configuration-path': 'bar' + }); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles('foo.pdf'); + getPullMock.mockResolvedValue({ + data: { + labels: [{name: 'touched-a-pdf-file'}] + } + }); + + usingLabelerConfigYaml('only_pdfs.yml'); + mockGitHubResponseChangedFiles('foo.pdf'); + + await run(); + + expect(updateLabelsMock).toHaveBeenCalledTimes(0); // No labels are created + expect(coreWarningMock).toHaveBeenCalledTimes(0); // No warnings issued + }); + test.each([ [new HttpError('Error message')], [new NotFound('Error message')] @@ -502,7 +577,28 @@ function usingLabelerConfigYaml(fixtureName: keyof typeof yamlFixtures): void { }); } +let mockRepoLabels: Array<{ + name: string; + color?: string; + description?: string; +}> = []; + function mockGitHubResponseChangedFiles(...files: string[]): void { const returnValue = files.map(f => ({filename: f})); - paginateMock.mockReturnValue(returnValue); + mockRepoLabels = []; + paginateMock.mockImplementation((options: any) => { + if (options?.__labelerMock === 'listFiles') { + return returnValue as any; + } + if (options?.__labelerMock === 'listLabelsForRepo') { + return mockRepoLabels as any; + } + throw new Error('Unexpected paginate options in test'); + }); +} + +function mockGitHubResponseRepoLabels( + labels: Array<{name: string; color?: string; description?: string}> +): void { + mockRepoLabels = labels; } diff --git a/dist/index.js b/dist/index.js index 83007c5ea..aed8640a4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -270,6 +270,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getLabelConfigs = void 0; exports.getLabelConfigMapFromObject = getLabelConfigMapFromObject; +exports.toLabelConfig = toLabelConfig; exports.toMatchConfig = toMatchConfig; const core = __importStar(__nccwpck_require__(7484)); const yaml = __importStar(__nccwpck_require__(4281)); @@ -278,6 +279,7 @@ const get_content_1 = __nccwpck_require__(6519); const changedFiles_1 = __nccwpck_require__(5145); const branch_1 = __nccwpck_require__(2234); const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const META_CONFIG_KEYS = ['description', 'color']; const getLabelConfigs = (client, configurationPath) => Promise.resolve() .then(() => { if (!fs_1.default.existsSync(configurationPath)) { @@ -336,6 +338,21 @@ function getLabelConfigMapFromObject(configObject) { updatedConfig.push({ any: [newMatchConfig] }); } } + else if (META_CONFIG_KEYS.includes(key)) { + // Convert scalar config entries into the object shape expected by toLabelConfig. + const metadata = toLabelConfig({ [key]: value }); + // Find or set the `meta` key so that we can add these properties to that rule, + // Or create a new `meta` key and add that to our array of configs. + const indexOfMeta = updatedConfig.findIndex(mc => !!mc['meta']); + if (indexOfMeta >= 0) { + const existingMeta = updatedConfig[indexOfMeta].meta || {}; + Object.assign(existingMeta, metadata); + updatedConfig[indexOfMeta].meta = existingMeta; + } + else { + updatedConfig.push({ meta: metadata }); + } + } else { // Log the key that we don't know what to do with. core.info(`An unknown config option was under ${label}: ${key}`); @@ -349,6 +366,31 @@ function getLabelConfigMapFromObject(configObject) { } return labelMap; } +function toLabelConfig(config) { + const metadata = {}; + if (typeof config.description === 'string') { + metadata.description = config.description; + } + else if (config.description !== undefined) { + core.warning(`Invalid value for "description". It should be a string.`); + } + if (typeof config.color === 'string') { + const rawColor = config.color.trim(); + const normalizedColor = rawColor.startsWith('#') + ? rawColor.slice(1) + : rawColor; + if (/^[0-9a-fA-F]{6}$/.test(normalizedColor)) { + metadata.color = normalizedColor; + } + else { + core.warning(`Invalid value for "color". It should be a 6-character hex color (e.g. "ff00ff").`); + } + } + else if (config.color !== undefined) { + core.warning(`Invalid value for "color". It should be a string.`); + } + return metadata; +} function toMatchConfig(config) { const changedFilesConfig = (0, changedFiles_1.toChangedFilesMatchConfig)(config); const branchConfig = (0, branch_1.toBranchMatchConfig)(config); @@ -436,7 +478,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.setLabels = void 0; +exports.updateLabels = exports.setLabels = void 0; const github = __importStar(__nccwpck_require__(3228)); const setLabels = (client, prNumber, labels) => __awaiter(void 0, void 0, void 0, function* () { yield client.rest.issues.setLabels({ @@ -447,6 +489,94 @@ const setLabels = (client, prNumber, labels) => __awaiter(void 0, void 0, void 0 }); }); exports.setLabels = setLabels; +// Function to update a list of labels +const updateLabels = (client, labels, labelConfigs, repoLabelCache) => __awaiter(void 0, void 0, void 0, function* () { + var _a, _b, _c, _d, _e, _f; + const labelMetaEntries = labels + .map(label => { + var _a, _b; + return ({ + label, + meta: (_b = (_a = labelConfigs.get(label)) === null || _a === void 0 ? void 0 : _a.find(config => config.meta)) === null || _b === void 0 ? void 0 : _b.meta + }); + }) + .filter(entry => entry.meta && (entry.meta.color || entry.meta.description)); + if (!labelMetaEntries.length) { + return; + } + if (repoLabelCache.size === 0) { + const listLabelsOptions = client.rest.issues.listLabelsForRepo.endpoint.merge({ + owner: github.context.repo.owner, + repo: github.context.repo.repo + }); + const repoLabels = (yield client.paginate(listLabelsOptions)); + for (const repoLabel of repoLabels) { + if (typeof repoLabel.name !== 'string') { + continue; + } + repoLabelCache.set(repoLabel.name, { + name: repoLabel.name, + color: (_a = repoLabel.color) !== null && _a !== void 0 ? _a : undefined, + description: (_b = repoLabel.description) !== null && _b !== void 0 ? _b : undefined + }); + } + } + for (const { label, meta: metadata } of labelMetaEntries) { + if (!metadata) { + continue; + } + const colorConfig = metadata.color; + const descriptionConfig = metadata.description; + const existingLabel = repoLabelCache.get(label); + if (!existingLabel) { + const createParams = { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + name: label + }; + if (colorConfig) { + createParams.color = colorConfig; + } + if (descriptionConfig) { + createParams.description = descriptionConfig; + } + yield client.rest.issues.createLabel(createParams); + repoLabelCache.set(label, { + name: label, + color: colorConfig !== null && colorConfig !== void 0 ? colorConfig : undefined, + description: descriptionConfig !== null && descriptionConfig !== void 0 ? descriptionConfig : undefined + }); + continue; + } + const existingColor = (_c = existingLabel.color) === null || _c === void 0 ? void 0 : _c.toLowerCase(); + const desiredColor = colorConfig === null || colorConfig === void 0 ? void 0 : colorConfig.toLowerCase(); + const colorMatches = desiredColor ? desiredColor === existingColor : true; + const descriptionMatches = descriptionConfig + ? descriptionConfig === ((_d = existingLabel.description) !== null && _d !== void 0 ? _d : undefined) + : true; + if (colorMatches && descriptionMatches) { + continue; + } + const updateParams = { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + name: label + }; + if (colorConfig) { + updateParams.color = colorConfig; + } + if (descriptionConfig) { + updateParams.description = descriptionConfig; + } + yield client.rest.issues.updateLabel(updateParams); + repoLabelCache.set(label, { + name: label, + color: (_e = colorConfig !== null && colorConfig !== void 0 ? colorConfig : existingLabel.color) !== null && _e !== void 0 ? _e : undefined, + description: (_f = descriptionConfig !== null && descriptionConfig !== void 0 ? descriptionConfig : existingLabel.description) !== null && _f !== void 0 ? _f : undefined + }); + } +}); +exports.updateLabels = updateLabels; /***/ }), @@ -1050,25 +1180,30 @@ exports.run = run; function labeler() { return __awaiter(this, void 0, void 0, function* () { var _a, e_1, _b, _c; + var _d, _e; const { token, configPath, syncLabels, dot, prNumbers } = (0, get_inputs_1.getInputs)(); if (!prNumbers.length) { core.warning('Could not get pull request number(s), exiting'); return; } const client = github.getOctokit(token, {}, pluginRetry.retry); + const repoLabelCache = new Map(); const pullRequests = api.getPullRequests(client, prNumbers); try { - for (var _d = true, pullRequests_1 = __asyncValues(pullRequests), pullRequests_1_1; pullRequests_1_1 = yield pullRequests_1.next(), _a = pullRequests_1_1.done, !_a; _d = true) { + for (var _f = true, pullRequests_1 = __asyncValues(pullRequests), pullRequests_1_1; pullRequests_1_1 = yield pullRequests_1.next(), _a = pullRequests_1_1.done, !_a; _f = true) { _c = pullRequests_1_1.value; - _d = false; + _f = false; const pullRequest = _c; const labelConfigs = yield api.getLabelConfigs(client, configPath); - const preexistingLabels = pullRequest.data.labels.map(l => l.name); - const allLabels = new Set(preexistingLabels); + const preexistingLabels = pullRequest.data.labels.map((l) => { var _a; return [l.name, (_a = l.color) !== null && _a !== void 0 ? _a : '']; }); + const preexistingLabelNames = new Set(preexistingLabels.map(([label]) => label)); + const allLabels = new Map(); + preexistingLabels.forEach(([label, color]) => allLabels.set(label, color)); for (const [label, configs] of labelConfigs.entries()) { core.debug(`processing ${label}`); if (checkMatchConfigs(pullRequest.changedFiles, configs, dot)) { - allLabels.add(label); + const labelColor = (_e = (_d = configs.find(config => config.meta)) === null || _d === void 0 ? void 0 : _d.meta) === null || _e === void 0 ? void 0 : _e.color; + allLabels.set(label, labelColor !== null && labelColor !== void 0 ? labelColor : ''); } else if (syncLabels) { allLabels.delete(label); @@ -1085,16 +1220,33 @@ function labeler() { // Skip fetching real labels when running tests (uses mock data instead) if (process.env.NODE_ENV !== 'test') { const pr = yield client.rest.pulls.get(Object.assign(Object.assign({}, github.context.repo), { pull_number: pullRequest.number })); - latestLabels.push(...pr.data.labels.map(l => l.name).filter(Boolean)); + latestLabels.push(...pr.data.labels + .map(l => { var _a; return [l.name, (_a = l.color) !== null && _a !== void 0 ? _a : '']; }) + .filter(([label]) => Boolean(label))); } // Labels added manually during the run (not in first snapshot) - const manualAddedDuringRun = latestLabels.filter(l => !preexistingLabels.includes(l)); + const manualAddedDuringRun = latestLabels.filter(([label]) => !preexistingLabelNames.has(label)); // Preserve manual labels first, then apply config-based labels, respecting GitHub's 100-label limit - finalLabels = [ - ...new Set([...manualAddedDuringRun, ...labelsToApply]) - ].slice(0, GITHUB_MAX_LABELS); - yield api.setLabels(client, pullRequest.number, finalLabels); - newLabels = finalLabels.filter(l => !preexistingLabels.includes(l)); + const mergedLabels = new Map(); + for (const [label, color] of manualAddedDuringRun) { + mergedLabels.set(label, color); + } + for (const [label, color] of labelsToApply) { + if (!mergedLabels.has(label)) { + mergedLabels.set(label, color); + } + else if (!mergedLabels.get(label) && color) { + mergedLabels.set(label, color); + } + } + finalLabels = [...mergedLabels].slice(0, GITHUB_MAX_LABELS); + yield api.setLabels(client, pullRequest.number, finalLabels.map(([label]) => label)); + newLabels = finalLabels + .filter(([label]) => !preexistingLabelNames.has(label)) + .map(([label]) => label); + } + if (newLabels.length > 0) { + yield api.updateLabels(client, newLabels, labelConfigs, repoLabelCache); } } catch (error) { @@ -1116,16 +1268,18 @@ function labeler() { return; } core.setOutput('new-labels', newLabels.join(',')); - core.setOutput('all-labels', finalLabels.join(',')); + core.setOutput('all-labels', finalLabels.map(([label]) => label).join(',')); if (excessLabels.length) { - core.warning(`Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join(', ')}`, { title: 'Label limit for a PR exceeded' }); + core.warning(`Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels + .map(([label]) => label) + .join(', ')}`, { title: 'Label limit for a PR exceeded' }); } } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { - if (!_d && !_a && (_b = pullRequests_1.return)) yield _b.call(pullRequests_1); + if (!_f && !_a && (_b = pullRequests_1.return)) yield _b.call(pullRequests_1); } finally { if (e_1) throw e_1.error; } } diff --git a/src/api/get-label-configs.ts b/src/api/get-label-configs.ts index 4db33f28e..482d1fe24 100644 --- a/src/api/get-label-configs.ts +++ b/src/api/get-label-configs.ts @@ -14,11 +14,18 @@ import {toBranchMatchConfig, BranchMatchConfig} from '../branch'; export interface MatchConfig { all?: BaseMatchConfig[]; any?: BaseMatchConfig[]; + meta?: LabelMetadata; +} + +export interface LabelMetadata { + description?: string; + color?: string; } export type BaseMatchConfig = BranchMatchConfig & ChangedFilesMatchConfig; const ALLOWED_CONFIG_KEYS = ['changed-files', 'head-branch', 'base-branch']; +const META_CONFIG_KEYS = ['description', 'color']; export const getLabelConfigs = ( client: ClientType, @@ -96,6 +103,19 @@ export function getLabelConfigMapFromObject( } else { updatedConfig.push({any: [newMatchConfig]}); } + } else if (META_CONFIG_KEYS.includes(key)) { + // Convert scalar config entries into the object shape expected by toLabelConfig. + const metadata = toLabelConfig({[key]: value}); + // Find or set the `meta` key so that we can add these properties to that rule, + // Or create a new `meta` key and add that to our array of configs. + const indexOfMeta = updatedConfig.findIndex(mc => !!mc['meta']); + if (indexOfMeta >= 0) { + const existingMeta = updatedConfig[indexOfMeta].meta || {}; + Object.assign(existingMeta, metadata); + updatedConfig[indexOfMeta].meta = existingMeta; + } else { + updatedConfig.push({meta: metadata}); + } } else { // Log the key that we don't know what to do with. core.info(`An unknown config option was under ${label}: ${key}`); @@ -115,6 +135,34 @@ export function getLabelConfigMapFromObject( return labelMap; } +export function toLabelConfig(config: any): LabelMetadata { + const metadata: LabelMetadata = {}; + + if (typeof config.description === 'string') { + metadata.description = config.description; + } else if (config.description !== undefined) { + core.warning(`Invalid value for "description". It should be a string.`); + } + + if (typeof config.color === 'string') { + const rawColor = config.color.trim(); + const normalizedColor = rawColor.startsWith('#') + ? rawColor.slice(1) + : rawColor; + if (/^[0-9a-fA-F]{6}$/.test(normalizedColor)) { + metadata.color = normalizedColor; + } else { + core.warning( + `Invalid value for "color". It should be a 6-character hex color (e.g. "ff00ff").` + ); + } + } else if (config.color !== undefined) { + core.warning(`Invalid value for "color". It should be a string.`); + } + + return metadata; +} + export function toMatchConfig(config: any): BaseMatchConfig { const changedFilesConfig = toChangedFilesMatchConfig(config); const branchConfig = toBranchMatchConfig(config); diff --git a/src/api/set-labels.ts b/src/api/set-labels.ts index 6d598535d..12832ba5e 100644 --- a/src/api/set-labels.ts +++ b/src/api/set-labels.ts @@ -1,5 +1,6 @@ import * as github from '@actions/github'; import {ClientType} from './types'; +import {MatchConfig} from './get-label-configs'; export const setLabels = async ( client: ClientType, @@ -13,3 +14,116 @@ export const setLabels = async ( labels: labels }); }; + +export type LabelConfigs = Map; + +type RepoLabel = { + name: string; + color?: string | null; + description?: string | null; +}; + +export type RepoLabelCache = Map; + +// Function to update a list of labels +export const updateLabels = async ( + client: ClientType, + labels: string[], + labelConfigs: LabelConfigs, + repoLabelCache: RepoLabelCache +) => { + const labelMetaEntries = labels + .map(label => ({ + label, + meta: labelConfigs.get(label)?.find(config => config.meta)?.meta + })) + .filter( + entry => entry.meta && (entry.meta.color || entry.meta.description) + ); + + if (!labelMetaEntries.length) { + return; + } + + if (repoLabelCache.size === 0) { + const listLabelsOptions = + client.rest.issues.listLabelsForRepo.endpoint.merge({ + owner: github.context.repo.owner, + repo: github.context.repo.repo + }); + const repoLabels = (await client.paginate( + listLabelsOptions + )) as RepoLabel[]; + for (const repoLabel of repoLabels) { + if (typeof repoLabel.name !== 'string') { + continue; + } + repoLabelCache.set(repoLabel.name, { + name: repoLabel.name, + color: repoLabel.color ?? undefined, + description: repoLabel.description ?? undefined + }); + } + } + + for (const {label, meta: metadata} of labelMetaEntries) { + if (!metadata) { + continue; + } + + const colorConfig = metadata.color; + const descriptionConfig = metadata.description; + const existingLabel = repoLabelCache.get(label); + + if (!existingLabel) { + const createParams: Parameters[0] = + { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + name: label + }; + if (colorConfig) { + createParams.color = colorConfig; + } + if (descriptionConfig) { + createParams.description = descriptionConfig; + } + await client.rest.issues.createLabel(createParams); + repoLabelCache.set(label, { + name: label, + color: colorConfig ?? undefined, + description: descriptionConfig ?? undefined + }); + continue; + } + + const existingColor = existingLabel.color?.toLowerCase(); + const desiredColor = colorConfig?.toLowerCase(); + const colorMatches = desiredColor ? desiredColor === existingColor : true; + const descriptionMatches = descriptionConfig + ? descriptionConfig === (existingLabel.description ?? undefined) + : true; + + if (colorMatches && descriptionMatches) { + continue; + } + + const updateParams: Parameters[0] = { + owner: github.context.repo.owner, + repo: github.context.repo.repo, + name: label + }; + if (colorConfig) { + updateParams.color = colorConfig; + } + if (descriptionConfig) { + updateParams.description = descriptionConfig; + } + await client.rest.issues.updateLabel(updateParams); + repoLabelCache.set(label, { + name: label, + color: colorConfig ?? existingLabel.color ?? undefined, + description: descriptionConfig ?? existingLabel.description ?? undefined + }); + } +}; diff --git a/src/labeler.ts b/src/labeler.ts index dfe438f27..5ef131671 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -31,6 +31,7 @@ export async function labeler() { } const client: ClientType = github.getOctokit(token, {}, pluginRetry.retry); + const repoLabelCache: api.RepoLabelCache = new Map(); const pullRequests = api.getPullRequests(client, prNumbers); @@ -39,13 +40,20 @@ export async function labeler() { client, configPath ); - const preexistingLabels = pullRequest.data.labels.map(l => l.name); - const allLabels: Set = new Set(preexistingLabels); + const preexistingLabels: [string, string][] = pullRequest.data.labels.map( + (l: {name: string; color?: string}) => [l.name, l.color ?? ''] + ); + const preexistingLabelNames = new Set( + preexistingLabels.map(([label]) => label) + ); + const allLabels = new Map(); + preexistingLabels.forEach(([label, color]) => allLabels.set(label, color)); for (const [label, configs] of labelConfigs.entries()) { core.debug(`processing ${label}`); if (checkMatchConfigs(pullRequest.changedFiles, configs, dot)) { - allLabels.add(label); + const labelColor = configs.find(config => config.meta)?.meta?.color; + allLabels.set(label, labelColor ?? ''); } else if (syncLabels) { allLabels.delete(label); } @@ -60,29 +68,52 @@ export async function labeler() { try { if (!isEqual(labelsToApply, preexistingLabels)) { // Fetch the latest labels for the PR - const latestLabels: string[] = []; + const latestLabels: [string, string][] = []; // Skip fetching real labels when running tests (uses mock data instead) if (process.env.NODE_ENV !== 'test') { const pr = await client.rest.pulls.get({ ...github.context.repo, pull_number: pullRequest.number }); - latestLabels.push(...pr.data.labels.map(l => l.name).filter(Boolean)); + latestLabels.push( + ...pr.data.labels + .map(l => [l.name, l.color ?? ''] as [string, string]) + .filter(([label]) => Boolean(label)) + ); } // Labels added manually during the run (not in first snapshot) const manualAddedDuringRun = latestLabels.filter( - l => !preexistingLabels.includes(l) + ([label]) => !preexistingLabelNames.has(label) ); // Preserve manual labels first, then apply config-based labels, respecting GitHub's 100-label limit - finalLabels = [ - ...new Set([...manualAddedDuringRun, ...labelsToApply]) - ].slice(0, GITHUB_MAX_LABELS); + const mergedLabels = new Map(); + for (const [label, color] of manualAddedDuringRun) { + mergedLabels.set(label, color); + } + for (const [label, color] of labelsToApply) { + if (!mergedLabels.has(label)) { + mergedLabels.set(label, color); + } else if (!mergedLabels.get(label) && color) { + mergedLabels.set(label, color); + } + } + finalLabels = [...mergedLabels].slice(0, GITHUB_MAX_LABELS); - await api.setLabels(client, pullRequest.number, finalLabels); + await api.setLabels( + client, + pullRequest.number, + finalLabels.map(([label]) => label) + ); + + newLabels = finalLabels + .filter(([label]) => !preexistingLabelNames.has(label)) + .map(([label]) => label); + } - newLabels = finalLabels.filter(l => !preexistingLabels.includes(l)); + if (newLabels.length > 0) { + await api.updateLabels(client, newLabels, labelConfigs, repoLabelCache); } } catch (error: any) { if ( @@ -115,13 +146,13 @@ export async function labeler() { } core.setOutput('new-labels', newLabels.join(',')); - core.setOutput('all-labels', finalLabels.join(',')); + core.setOutput('all-labels', finalLabels.map(([label]) => label).join(',')); if (excessLabels.length) { core.warning( - `Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels.join( - ', ' - )}`, + `Maximum of ${GITHUB_MAX_LABELS} labels allowed. Excess labels: ${excessLabels + .map(([label]) => label) + .join(', ')}`, {title: 'Label limit for a PR exceeded'} ); }