diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 4d166ae3ead919..672a26b7def432 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2159,10 +2159,10 @@ Consider this example: With the above config, every PR raised by Renovate will have the label `dependencies` while PRs containing `eslint`-related packages will instead have the label `linting`. -Renovate only adds labels when it creates the PR, which means: +Behaviour details: -- If you remove labels which Renovate added, it won't re-apply them -- If you change your config, the new/changed labels are not applied to any open PRs +- On GitHub, GitLab and Gitea: Renovate will keep PR labels in sync with configured labels, provided that no other user or bot has made changes to the labels after PR creation. If labels are changed by any other account, Renovate will stop making further changes. +- For other platforms, Renovate will add labels only at time of PR creation and not update them after that. The `labels` array is non-mergeable, meaning if multiple `packageRules` match then Renovate uses the last value for `labels`. If you want to add/combine labels, use the `addLabels` config option, which is mergeable. diff --git a/lib/modules/platform/gitea/gitea-helper.spec.ts b/lib/modules/platform/gitea/gitea-helper.spec.ts index 28bcf498247421..3b5a3112a4e0ff 100644 --- a/lib/modules/platform/gitea/gitea-helper.spec.ts +++ b/lib/modules/platform/gitea/gitea-helper.spec.ts @@ -377,8 +377,7 @@ describe('modules/platform/gitea/gitea-helper', () => { .patch(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}`) .reply(200); - const res = await closePR(mockRepo.full_name, mockPR.number); - expect(res).toBeUndefined(); + await expect(closePR(mockRepo.full_name, mockPR.number)).toResolve(); }); }); @@ -389,10 +388,11 @@ describe('modules/platform/gitea/gitea-helper', () => { .post(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}/merge`) .reply(200); - const res = await mergePR(mockRepo.full_name, mockPR.number, { - Do: 'rebase', - }); - expect(res).toBeUndefined(); + await expect( + mergePR(mockRepo.full_name, mockPR.number, { + Do: 'rebase', + }), + ).toResolve(); }); }); @@ -569,12 +569,9 @@ describe('modules/platform/gitea/gitea-helper', () => { ) .reply(200); - const res = await unassignLabel( - mockRepo.full_name, - mockIssue.number, - mockLabel.id, - ); - expect(res).toBeUndefined(); + await expect( + unassignLabel(mockRepo.full_name, mockIssue.number, mockLabel.id), + ).toResolve(); }); }); diff --git a/lib/modules/platform/gitea/index.spec.ts b/lib/modules/platform/gitea/index.spec.ts index 2a8de9e8c40bad..1f5c598460ca87 100644 --- a/lib/modules/platform/gitea/index.spec.ts +++ b/lib/modules/platform/gitea/index.spec.ts @@ -106,6 +106,12 @@ describe('modules/platform/gitea/index', () => { sha: 'other-head-sha' as LongCommitSha, repo: partial({ full_name: mockRepo.full_name }), }, + labels: [ + { + id: 1, + name: 'bug', + }, + ], }), partial({ number: 3, @@ -1830,6 +1836,87 @@ describe('modules/platform/gitea/index', () => { }), ).toResolve(); }); + + it('should update labels', async () => { + const updatedMockPR = partial({ + ...mockPRs[0], + number: 1, + title: 'New Title', + body: 'New Body', + state: 'open', + labels: [ + { + id: 1, + name: 'some-label', + }, + ], + }); + const scope = httpMock + .scope('https://gitea.com/api/v1') + .get('/repos/some/repo/pulls') + .query({ state: 'all', sort: 'recentupdate' }) + .reply(200, mockPRs) + .get('/repos/some/repo/labels') + .reply(200, mockRepoLabels) + .get('/orgs/some/labels') + .reply(200, mockOrgLabels) + .patch('/repos/some/repo/pulls/1') + .reply(200, updatedMockPR); + + await initFakePlatform(scope); + await initFakeRepo(scope); + await expect( + gitea.updatePr({ + number: 1, + prTitle: 'New Title', + prBody: 'New Body', + state: 'open', + labels: ['some-label'], + }), + ).toResolve(); + }); + + it('should log a warning if labels could not be looked up', async () => { + const updatedMockPR = partial({ + ...mockPRs[0], + number: 1, + title: 'New Title', + body: 'New Body', + state: 'open', + labels: [ + { + id: 1, + name: 'some-label', + }, + ], + }); + const scope = httpMock + .scope('https://gitea.com/api/v1') + .get('/repos/some/repo/pulls') + .query({ state: 'all', sort: 'recentupdate' }) + .reply(200, mockPRs) + .get('/repos/some/repo/labels') + .reply(200, mockRepoLabels) + .get('/orgs/some/labels') + .reply(200, mockOrgLabels) + .patch('/repos/some/repo/pulls/1') + .reply(200, updatedMockPR); + + await initFakePlatform(scope); + await initFakeRepo(scope); + await expect( + gitea.updatePr({ + number: 1, + prTitle: 'New Title', + prBody: 'New Body', + state: 'open', + labels: ['some-label', 'unavailable-label'], + }), + ).toResolve(); + expect(logger.warn).toHaveBeenCalledWith( + 'Some labels could not be looked up. Renovate may halt label updates assuming changes by others.', + ); + }); }); describe('mergePr', () => { diff --git a/lib/modules/platform/gitea/index.ts b/lib/modules/platform/gitea/index.ts index dd03749ca263f5..f55c94da9fde95 100644 --- a/lib/modules/platform/gitea/index.ts +++ b/lib/modules/platform/gitea/index.ts @@ -490,7 +490,7 @@ const platform: Platform = { logger.debug(`Creating pull request: ${title} (${head} => ${base})`); try { const labels = Array.isArray(labelNames) - ? await Promise.all(labelNames.map(lookupLabelByName)) + ? await map(labelNames, lookupLabelByName) : []; const gpr = await helper.createPR(config.repository, { base, @@ -583,6 +583,7 @@ const platform: Platform = { number, prTitle, prBody: body, + labels, state, targetBranch, }: UpdatePrConfig): Promise { @@ -600,6 +601,24 @@ const platform: Platform = { prUpdateParams.base = targetBranch; } + /** + * Update PR labels. + * In the Gitea API, labels are replaced on each update if the field is present. + * If the field is not present (i.e., undefined), labels aren't updated. + * However, the labels array must contain label IDs instead of names, + * so a lookup is performed to fetch the details (including the ID) of each label. + */ + if (Array.isArray(labels)) { + prUpdateParams.labels = (await map(labels, lookupLabelByName)).filter( + is.number, + ); + if (labels.length !== prUpdateParams.labels.length) { + logger.warn( + 'Some labels could not be looked up. Renovate may halt label updates assuming changes by others.', + ); + } + } + const gpr = await helper.updatePR( config.repository, number, diff --git a/lib/modules/platform/gitea/types.ts b/lib/modules/platform/gitea/types.ts index 877ea4436d0881..87cc3788f32f72 100644 --- a/lib/modules/platform/gitea/types.ts +++ b/lib/modules/platform/gitea/types.ts @@ -17,6 +17,10 @@ export type CommitStatusType = | 'unknown'; export type PRMergeMethod = 'merge' | 'rebase' | 'rebase-merge' | 'squash'; +export interface GiteaLabel { + id: number; + name: string; +} export interface PR { number: number; state: PRState; @@ -40,6 +44,10 @@ export interface PR { }; assignees?: any[]; user?: { username?: string }; + + // labels returned from the Gitea API are represented as an array of objects + // ref: https://docs.gitea.com/api/1.20/#tag/repository/operation/repoGetPullRequest + labels?: GiteaLabel[]; } export interface Issue { diff --git a/lib/modules/platform/gitea/utils.ts b/lib/modules/platform/gitea/utils.ts index d4e5bd5bb32919..b29411e6585c43 100644 --- a/lib/modules/platform/gitea/utils.ts +++ b/lib/modules/platform/gitea/utils.ts @@ -119,8 +119,10 @@ export function toRenovatePR(data: PR, author: string | null): Pr | null { title = title.substring(DRAFT_PREFIX.length); isDraft = true; } + const labels = (data?.labels ?? []).map((l) => l.name); return { + labels, number: data.number, state: data.state, title, diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index d0a33bb4ea4681..c8b50121581187 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -3328,6 +3328,43 @@ describe('modules/platform/github/index', () => { await expect(github.updatePr(pr)).toResolve(); }); + + it('should add and remove labels', async () => { + const pr: UpdatePrConfig = { + number: 1234, + prTitle: 'The New Title', + prBody: 'Hello world again', + state: 'closed', + targetBranch: 'new_base', + addLabels: ['new_label'], + removeLabels: ['old_label'], + }; + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + await github.initRepo({ repository: 'some/repo' }); + scope + .patch('/repos/some/repo/pulls/1234') + .reply(200, { + number: 91, + base: { sha: '1234' }, + head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, + state: 'open', + title: 'old title', + updated_at: '01-09-2022', + }) + .post('/repos/some/repo/issues/1234/labels') + .reply(200, pr) + .delete('/repos/some/repo/issues/1234/labels/old_label') + .reply(200, pr); + + await expect(github.updatePr(pr)).toResolve(); + expect(logger.logger.debug).toHaveBeenCalledWith( + `Adding labels 'new_label' to #1234`, + ); + expect(logger.logger.debug).toHaveBeenCalledWith( + `Deleting label old_label from #1234`, + ); + }); }); describe('mergePr(prNo)', () => { diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index e629dd6e6e6b43..d5ddd2757372c3 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -1756,6 +1756,8 @@ export async function updatePr({ number: prNo, prTitle: title, prBody: rawBody, + addLabels: labelsToAdd, + removeLabels, state, targetBranch, }: UpdatePrConfig): Promise { @@ -1778,7 +1780,19 @@ export async function updatePr({ if (config.forkToken) { options.token = config.forkToken; } + + // Update PR labels try { + if (labelsToAdd) { + await addLabels(prNo, labelsToAdd); + } + + if (removeLabels) { + for (const label of removeLabels) { + await deleteLabel(prNo, label); + } + } + const { body: ghPr } = await githubApi.patchJson( `repos/${config.parentRepo ?? config.repository}/pulls/${prNo}`, options, diff --git a/lib/modules/platform/gitlab/index.spec.ts b/lib/modules/platform/gitlab/index.spec.ts index 676df2e47ef18f..33c11f4974957e 100644 --- a/lib/modules/platform/gitlab/index.spec.ts +++ b/lib/modules/platform/gitlab/index.spec.ts @@ -2874,6 +2874,35 @@ describe('modules/platform/gitlab/index', () => { }), ).toResolve(); }); + + it('adds and removes labels', async () => { + await initPlatform('13.3.6-ee'); + httpMock + .scope(gitlabApiHost) + .get( + '/api/v4/projects/undefined/merge_requests?per_page=100&scope=created_by_me', + ) + .reply(200, [ + { + iid: 1, + source_branch: 'branch-a', + title: 'branch a pr', + state: 'open', + }, + ]) + .put('/api/v4/projects/undefined/merge_requests/1') + .reply(200); + await expect( + gitlab.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'body', + state: 'closed', + addLabels: ['new_label'], + removeLabels: ['old_label'], + }), + ).toResolve(); + }); }); describe('mergePr(pr)', () => { diff --git a/lib/modules/platform/gitlab/index.ts b/lib/modules/platform/gitlab/index.ts index a84b83868c0cf7..eaed6a0309157b 100644 --- a/lib/modules/platform/gitlab/index.ts +++ b/lib/modules/platform/gitlab/index.ts @@ -817,6 +817,8 @@ export async function updatePr({ number: iid, prTitle, prBody: description, + addLabels, + removeLabels, state, platformOptions, targetBranch, @@ -840,6 +842,14 @@ export async function updatePr({ body.target_branch = targetBranch; } + if (addLabels) { + body.add_labels = addLabels; + } + + if (removeLabels) { + body.remove_labels = removeLabels; + } + await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${iid}`, { body }, diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index b6f75f2a58588f..1766cdea3fddd2 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -56,6 +56,7 @@ export interface PrDebugData { createdInVer: string; updatedInVer: string; targetBranch: string; + labels?: string[]; } export interface PrBodyStruct { @@ -122,6 +123,28 @@ export interface UpdatePrConfig { prBody?: string; state?: 'open' | 'closed'; targetBranch?: string; + + /** + * This field allows for label management and is designed to + * accommodate the different label update methods on various platforms. + * + * - For Gitea, labels are updated by replacing the entire labels array. + * - In the case of GitHub and GitLab, specific endpoints exist + * for adding and removing labels. + */ + labels?: string[] | null; + + /** + * Specifies an array of labels to be added. + * @see {@link labels} + */ + addLabels?: string[] | null; + + /** + * Specifies an array of labels to be removed. + * @see {@link labels} + */ + removeLabels?: string[] | null; } export interface EnsureIssueConfig { title: string; @@ -211,6 +234,7 @@ export interface Platform { getRepos(config?: AutodiscoverConfig): Promise; getBranchForceRebase?(branchName: string): Promise; deleteLabel(number: number, label: string): Promise; + addLabel?(number: number, label: string): Promise; setBranchStatus(branchStatusConfig: BranchStatusConfig): Promise; getBranchStatusCheck( branchName: string, diff --git a/lib/workers/repository/config-migration/pr/index.spec.ts b/lib/workers/repository/config-migration/pr/index.spec.ts index 7792174a60a70f..f104d009750d7d 100644 --- a/lib/workers/repository/config-migration/pr/index.spec.ts +++ b/lib/workers/repository/config-migration/pr/index.spec.ts @@ -144,8 +144,8 @@ describe('workers/repository/config-migration/pr/index', () => { ); expect(platform.createPr).toHaveBeenCalledTimes(1); expect(platform.createPr.mock.calls[0][0].labels).toEqual([ - 'label', 'additional-label', + 'label', ]); }); diff --git a/lib/workers/repository/onboarding/pr/index.spec.ts b/lib/workers/repository/onboarding/pr/index.spec.ts index ba26fbbfaf414a..030ce3680d93de 100644 --- a/lib/workers/repository/onboarding/pr/index.spec.ts +++ b/lib/workers/repository/onboarding/pr/index.spec.ts @@ -106,8 +106,8 @@ describe('workers/repository/onboarding/pr/index', () => { ); expect(platform.createPr).toHaveBeenCalledTimes(1); expect(platform.createPr.mock.calls[0][0].labels).toEqual([ - 'label', 'additional-label', + 'label', ]); }); diff --git a/lib/workers/repository/update/pr/index.spec.ts b/lib/workers/repository/update/pr/index.spec.ts index d50959fb92ec6b..c0a8f6ac090895 100644 --- a/lib/workers/repository/update/pr/index.spec.ts +++ b/lib/workers/repository/update/pr/index.spec.ts @@ -19,6 +19,7 @@ import type { Pr } from '../../../../modules/platform/types'; import { ExternalHostError } from '../../../../types/errors/external-host-error'; import type { PrCache } from '../../../../util/cache/repository/types'; import { fingerprint } from '../../../../util/fingerprint'; +import { toBase64 } from '../../../../util/string'; import * as _limits from '../../../global/limits'; import type { BranchConfig, BranchUpgradeConfig } from '../../../types'; import { embedChangelogs } from '../../changelog'; @@ -269,6 +270,137 @@ describe('workers/repository/update/pr/index', () => { }); describe('Update', () => { + it('updates PR if labels have changed in config', async () => { + const prDebugData = { + createdInVer: '1.0.0', + targetBranch: 'main', + labels: ['old_label'], + }; + + const existingPr: Pr = { + ...pr, + bodyStruct: getPrBodyStruct( + `\n\n Some body`, + ), + labels: ['old_label'], + }; + platform.getBranchPr.mockResolvedValueOnce(existingPr); + prBody.getPrBody.mockReturnValueOnce( + `\n\n Some body`, + ); + config.labels = ['new_label']; + const res = await ensurePr(config); + + expect(res).toEqual({ + type: 'with-pr', + pr: { + ...pr, + labels: ['old_label'], + bodyStruct: { + hash: expect.any(String), + debugData: { + createdInVer: '1.0.0', + labels: ['new_label'], + targetBranch: 'main', + }, + }, + }, + }); + expect(platform.updatePr).toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.debug).toHaveBeenCalledWith( + { + branchName: 'renovate-branch', + prCurrentLabels: ['old_label'], + configuredLabels: ['new_label'], + }, + `PR labels have changed`, + ); + expect(prCache.setPrCache).toHaveBeenCalled(); + }); + + it('skips pr update if existing pr does not have labels in debugData', async () => { + const existingPr: Pr = { + ...pr, + labels: ['old_label'], + }; + platform.getBranchPr.mockResolvedValueOnce(existingPr); + + config.labels = ['new_label']; + const res = await ensurePr(config); + + expect(res).toEqual({ + type: 'with-pr', + pr: { ...pr, labels: ['old_label'] }, + }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.debug).not.toHaveBeenCalledWith( + { + branchName: 'renovate-branch', + oldLabels: ['old_label'], + newLabels: ['new_label'], + }, + `PR labels have changed`, + ); + expect(prCache.setPrCache).toHaveBeenCalled(); + }); + + it('skips pr update if pr labels have been modified by user', async () => { + const prDebugData = { + createdInVer: '1.0.0', + targetBranch: 'main', + labels: ['old_label'], + }; + + const existingPr: Pr = { + ...pr, + bodyStruct: getPrBodyStruct( + `\n\n Some body`, + ), + }; + platform.getBranchPr.mockResolvedValueOnce(existingPr); + + config.labels = ['new_label']; + const res = await ensurePr(config); + + expect(res).toEqual({ + type: 'with-pr', + pr: { + ...pr, + bodyStruct: { + hash: expect.any(String), + debugData: { + createdInVer: '1.0.0', + labels: ['old_label'], + targetBranch: 'main', + }, + }, + }, + }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.debug).not.toHaveBeenCalledWith( + { + branchName: 'renovate-branch', + prCurrentLabels: ['old_label'], + configuredLabels: ['new_label'], + }, + `PR labels have changed`, + ); + expect(logger.logger.debug).toHaveBeenCalledWith( + { prInitialLabels: ['old_label'], prCurrentLabels: [] }, + 'PR labels have been modified by user, skipping labels update', + ); + expect(prCache.setPrCache).toHaveBeenCalled(); + }); + it('updates PR due to title change', async () => { const changedPr: Pr = { ...pr, title: 'Another title' }; // user changed the prTitle platform.getBranchPr.mockResolvedValueOnce(changedPr); diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index bd136d0a3bd91a..e4e11d7738a4ce 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -36,7 +36,7 @@ import type { import { embedChangelogs } from '../../changelog'; import { resolveBranchStatus } from '../branch/status-checks'; import { getPrBody } from './body'; -import { prepareLabels } from './labels'; +import { getChangedLabels, prepareLabels, shouldUpdateLabels } from './labels'; import { addParticipants } from './participants'; import { getPrCache, setPrCache } from './pr-cache'; import { @@ -78,15 +78,27 @@ export type EnsurePrResult = ResultWithPr | ResultWithoutPr; export function updatePrDebugData( targetBranch: string, + labels: string[], debugData: PrDebugData | undefined, ): PrDebugData { const createdByRenovateVersion = debugData?.createdInVer ?? pkg.version; const updatedByRenovateVersion = pkg.version; - return { + + const updatedPrDebugData: PrDebugData = { createdInVer: createdByRenovateVersion, updatedInVer: updatedByRenovateVersion, targetBranch, }; + + // Add labels to the debug data object. + // When to add: + // 1. Add it when a new PR is created, i.e., when debugData is undefined. + // 2. Add it if an existing PR already has labels in the debug data, confirming that we can update its labels. + if (!debugData || is.array(debugData.labels)) { + updatedPrDebugData.labels = labels; + } + + return updatedPrDebugData; } function hasNotIgnoredReviewers(pr: Pr, config: BranchConfig): boolean { @@ -319,6 +331,7 @@ export async function ensurePr( { debugData: updatePrDebugData( config.baseBranch, + prepareLabels(config), // include labels in debug data existingPr?.bodyStruct?.debugData, ), }, @@ -344,10 +357,22 @@ export async function ensurePr( const existingPrBodyHash = existingPr.bodyStruct?.hash; const newPrTitle = stripEmojis(prTitle); const newPrBodyHash = hashBody(prBody); + + const prInitialLabels = existingPr.bodyStruct?.debugData?.labels; + const prCurrentLabels = existingPr.labels; + const configuredLabels = prepareLabels(config); + + const labelsNeedUpdate = shouldUpdateLabels( + prInitialLabels, + prCurrentLabels, + configuredLabels, + ); + if ( existingPr?.targetBranch === config.baseBranch && existingPrTitle === newPrTitle && - existingPrBodyHash === newPrBodyHash + existingPrBodyHash === newPrBodyHash && + !labelsNeedUpdate ) { // adds or-cache for existing PRs setPrCache(branchName, prBodyFingerprint, false); @@ -375,6 +400,36 @@ export async function ensurePr( ); updatePrConfig.targetBranch = config.baseBranch; } + + if (labelsNeedUpdate) { + logger.debug( + { + branchName, + prCurrentLabels, + configuredLabels, + }, + 'PR labels have changed', + ); + + // Divide labels into three categories: + // i) addLabels: Labels that need to be added + // ii) removeLabels: Labels that need to be removed + // iii) labels: New labels for the PR, replacing the old labels array entirely. + // This distinction is necessary because different platforms update labels differently + // For more details, refer to the updatePr function of each platform. + + const [addLabels, removeLabels] = getChangedLabels( + prCurrentLabels, + configuredLabels, + ); + + // for Gitea + updatePrConfig.labels = configuredLabels; + + // for GitHub, GitLab + updatePrConfig.addLabels = addLabels; + updatePrConfig.removeLabels = removeLabels; + } if (existingPrTitle !== newPrTitle) { logger.debug( { diff --git a/lib/workers/repository/update/pr/labels.spec.ts b/lib/workers/repository/update/pr/labels.spec.ts index 1c090ba3e96868..7dca6fbc4517ee 100644 --- a/lib/workers/repository/update/pr/labels.spec.ts +++ b/lib/workers/repository/update/pr/labels.spec.ts @@ -1,4 +1,9 @@ -import { prepareLabels } from './labels'; +import { + areLabelsModified, + getChangedLabels, + prepareLabels, + shouldUpdateLabels, +} from './labels'; describe('workers/repository/update/pr/labels', () => { describe('prepareLabels(config)', () => { @@ -78,4 +83,59 @@ describe('workers/repository/update/pr/labels', () => { expect(result).toEqual([]); }); }); + + describe('getChangedLabels', () => { + it('adds new labels', () => { + expect(getChangedLabels(['npm'], ['node', 'npm'])).toEqual([ + ['node'], + [], + ]); + }); + + it('removes old labels', () => { + expect(getChangedLabels(['node', 'npm'], ['npm'])).toEqual([ + [], + ['node'], + ]); + }); + }); + + describe('areLabelsModified', () => { + it('returns true', () => { + expect(areLabelsModified(['npm', 'node'], ['npm'])).toBeTrue(); + }); + + it('returns false', () => { + expect(areLabelsModified(['node', 'npm'], ['node', 'npm'])).toBeFalse(); + expect(areLabelsModified([], [])).toBeFalse(); + }); + }); + + describe('shouldUpdateLabels', () => { + it('returns true', () => { + expect( + shouldUpdateLabels(['npm', 'node'], ['npm', 'node'], ['npm']), + ).toBeTrue(); + expect( + shouldUpdateLabels(['npm', 'node'], ['npm', 'node'], undefined), + ).toBeTrue(); + expect(shouldUpdateLabels([], [], ['npm', 'node'])).toBeTrue(); + }); + + it('returns false if no labels found in debugData', () => { + expect( + shouldUpdateLabels(undefined, ['npm', 'node'], ['npm', 'node']), + ).toBeFalse(); + }); + + it('returns false if labels have been modified by user', () => { + expect(shouldUpdateLabels(['npm', 'node'], ['npm'], ['npm'])).toBeFalse(); + }); + + it('returns false if labels are not changed', () => { + expect( + shouldUpdateLabels(['npm', 'node'], ['npm', 'node'], ['npm', 'node']), + ).toBeFalse(); + }); + }); }); diff --git a/lib/workers/repository/update/pr/labels.ts b/lib/workers/repository/update/pr/labels.ts index df04f801259346..3c41e47b19740f 100644 --- a/lib/workers/repository/update/pr/labels.ts +++ b/lib/workers/repository/update/pr/labels.ts @@ -1,5 +1,7 @@ import is from '@sindresorhus/is'; +import { dequal } from 'dequal'; import type { RenovateConfig } from '../../../../config/types'; +import { logger } from '../../../../logger'; import * as template from '../../../../util/template'; export function prepareLabels(config: RenovateConfig): string[] { @@ -8,5 +10,74 @@ export function prepareLabels(config: RenovateConfig): string[] { return [...new Set([...labels, ...addLabels])] .filter(is.nonEmptyStringAndNotWhitespace) .map((label) => template.compile(label, config)) - .filter(is.nonEmptyStringAndNotWhitespace); + .filter(is.nonEmptyStringAndNotWhitespace) + .sort(); +} + +/** + * Determine changed labels between old and new label arrays. + * + * This function takes two arrays of labels, 'oldLabels' and 'newLabels', and calculates the labels + * that need to be added and removed to transition from 'oldLabels' to 'newLabels'. + */ +export function getChangedLabels( + oldLabels: string[] | undefined, + newLabels: string[] | undefined, +): [string[] | undefined, string[] | undefined] { + const labelsToAdd = newLabels?.filter((l) => !oldLabels?.includes(l)); + const labelsToRemove = oldLabels?.filter((l) => !newLabels?.includes(l)); + + return [labelsToAdd, labelsToRemove]; +} + +/** + * Check if labels in the PR have been modified. + * + * This function compares two arrays of labels, 'prInitialLabels' and 'prCurrentLabels', + * to determine if they are different, indicating that labels in the PR have been modified. + */ +export function areLabelsModified( + prInitialLabels: string[], + prCurrentLabels: string[], +): boolean { + const modified = !dequal(prInitialLabels.sort(), prCurrentLabels.sort()); + + if (modified) { + logger.debug( + { prInitialLabels, prCurrentLabels }, + 'PR labels have been modified by user, skipping labels update', + ); + } + + return modified; +} + +/** + * Determine if labels should be updated in the Pull Request. + */ +export function shouldUpdateLabels( + prInitialLabels: string[] | undefined, + prCurrentLabels: string[] | undefined, + configuredLabels: string[] | undefined, +): boolean { + // If the 'labelsInDebugData' field is undefined + // it means the PR was created before the update-labels logic was merged, and labels should not be updated. + // Reference: https://github.com/renovatebot/renovate/pull/25340 + if (!is.array(prInitialLabels)) { + return false; + } + + // If the labels are unchanged, they should not be updated + if (dequal((configuredLabels ?? []).sort(), prInitialLabels.sort())) { + return false; + } + + // If the labels in the PR have been modified by the user, they should not be updated + if (areLabelsModified(prInitialLabels, prCurrentLabels ?? [])) { + logger.debug('Labels have been modified by user - skipping labels update.'); + return false; + } + + logger.debug('Labels have been changed in repo config- updating labels.'); + return true; }