diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index e76873af8a49d0..1a037ca84001a8 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -293,6 +293,27 @@ If you prefer that Renovate more silently automerge _without_ Pull Requests at a The final value for `automergeType` is `"pr-comment"`, intended only for users who already have a "merge bot" such as [bors-ng](https://github.com/bors-ng/bors-ng) and want Renovate to _not_ actually automerge by itself and instead tell `bors-ng` to merge for it, by using a comment in the PR. If you're not already using `bors-ng` or similar, don't worry about this option. +## azureBypassPolicyMinimumNumberOfReviewers + +If you have branch policies in Azure DevOps that require a minimum number of reviewers, you can set this to `true` to bypass this policy. + +## azureBypassPolicyReason + +If renovate uses bypass to automerge this will be the reason for the bypass. + +## azureBypassPolicyRequiredReviewers + +If you have branch policies in Azure DevOps that set required reviewers, you can set this to `true` to bypass this policy. + +## azureBypassPolicyTypeUuids + +If you have branch policies in Azure DevOps that prevent Renovate from merging PRs, you can set this to a list of UUIDs of the policies you want to bypass. + +## azureBypassPolicyWorkItemLinking + +If you have branch policies in Azure DevOps that require work item linking, you can set this to `true` to bypass this policy +This can be used instead of setting `azureWorkItemId`. + ## azureWorkItemId When creating a PR in Azure DevOps, some branches can be protected with branch policies to [check for linked work items](https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#check-for-linked-work-items). diff --git a/lib/config/__snapshots__/validation.spec.ts.snap b/lib/config/__snapshots__/validation.spec.ts.snap index 4bfe7e328db719..45661d7953f3b0 100644 --- a/lib/config/__snapshots__/validation.spec.ts.snap +++ b/lib/config/__snapshots__/validation.spec.ts.snap @@ -37,6 +37,26 @@ exports[`config/validation validateConfig(config) catches invalid templates 1`] exports[`config/validation validateConfig(config) errors for all types 1`] = ` [ + { + "message": "Configuration option \`azureBypassPolicyMinimumNumberOfReviewers\` should be boolean. Found: 1 (number)", + "topic": "Configuration Error", + }, + { + "message": "Configuration option \`azureBypassPolicyReason\` should be boolean. Found: 1 (number)", + "topic": "Configuration Error", + }, + { + "message": "Configuration option \`azureBypassPolicyRequiredReviewers\` should be boolean. Found: 1 (number)", + "topic": "Configuration Error", + }, + { + "message": "Configuration option \`azureBypassPolicyTypeUuids\` should be a list (Array)", + "topic": "Configuration Error", + }, + { + "message": "Configuration option \`azureBypassPolicyWorkItemLinking\` should be boolean. Found: 1 (number)", + "topic": "Configuration Error", + }, { "message": "Configuration option \`azureWorkItemId\` should be an integer. Found: false (boolean).", "topic": "Configuration Error", diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index ea5da7747594a5..de8f1b623bc4e9 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1219,6 +1219,42 @@ const options: RenovateOptions[] = [ default: 0, supportedPlatforms: ['azure'], }, + { + name: 'azureBypassPolicyRequiredReviewers', + description: 'Allow to bypass the policy for required reviewers.', + type: 'boolean', + default: false, + supportedPlatforms: ['azure'], + }, + { + name: 'azureBypassPolicyMinimumNumberOfReviewers', + description: 'Allow to bypass the policy for minimum number of reviewers.', + type: 'boolean', + default: false, + supportedPlatforms: ['azure'], + }, + { + name: 'azureBypassPolicyWorkItemLinking', + description: 'Allow to bypass the policy for work item linking.', + type: 'boolean', + default: false, + supportedPlatforms: ['azure'], + }, + { + name: 'azureBypassPolicyTypeUuids', + description: 'A list of policy type UUIDs to allow bypassing.', + type: 'array', + subType: 'string', + default: [], + supportedPlatforms: ['azure'], + }, + { + name: 'azureBypassPolicyReason', + description: 'The reason to set when bypassing policies.', + type: 'boolean', + default: false, + supportedPlatforms: ['azure'], + }, { name: 'autoApprove', description: 'Set to `true` to automatically approve PRs.', diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index 927650a07d732d..1531742b6bf98f 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -497,6 +497,11 @@ describe('config/validation', () => { timezone: 'Asia', labels: 5 as any, azureWorkItemId: false as any, + azureBypassPolicyRequiredReviewers: 1 as any, + azureBypassPolicyMinimumNumberOfReviewers: 1 as any, + azureBypassPolicyWorkItemLinking: 1 as any, + azureBypassPolicyTypeUuids: 2 as any, + azureBypassPolicyReason: 1 as any, semanticCommitType: 7 as any, lockFileMaintenance: false as any, extends: [':timezone(Europe/Brussel)'], @@ -519,7 +524,7 @@ describe('config/validation', () => { ); expect(warnings).toHaveLength(1); expect(errors).toMatchSnapshot(); - expect(errors).toHaveLength(12); + expect(errors).toHaveLength(17); }); it('selectors outside packageRules array trigger errors', async () => { diff --git a/lib/modules/datasource/artifactory/__snapshots__/index.spec.ts.snap b/lib/modules/datasource/artifactory/__snapshots__/index.spec.ts.snap index e32ed5639d2d11..d3509e9082f562 100644 --- a/lib/modules/datasource/artifactory/__snapshots__/index.spec.ts.snap +++ b/lib/modules/datasource/artifactory/__snapshots__/index.spec.ts.snap @@ -5,19 +5,19 @@ exports[`modules/datasource/artifactory/index getReleases parses real data (file "registryUrl": "https://jfrog.company.com/artifactory", "releases": [ { - "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "releaseTimestamp": "2021-07-21T21:08:00.000Z", "version": "1.0.0", }, { - "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "releaseTimestamp": "2021-08-23T21:03:00.000Z", "version": "1.0.1", }, { - "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "releaseTimestamp": "2021-07-21T21:09:00.000Z", "version": "1.0.2", }, { - "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "releaseTimestamp": "2021-02-06T10:54:00.000Z", "version": "1.0.3", }, ], @@ -29,19 +29,19 @@ exports[`modules/datasource/artifactory/index getReleases parses real data (fold "registryUrl": "https://jfrog.company.com/artifactory", "releases": [ { - "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "releaseTimestamp": "2021-07-21T21:08:00.000Z", "version": "1.0.0", }, { - "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "releaseTimestamp": "2021-08-23T21:03:00.000Z", "version": "1.0.1", }, { - "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "releaseTimestamp": "2021-07-21T21:09:00.000Z", "version": "1.0.2", }, { - "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "releaseTimestamp": "2021-02-06T10:54:00.000Z", "version": "1.0.3", }, ], @@ -53,22 +53,22 @@ exports[`modules/datasource/artifactory/index getReleases parses real data (merg "releases": [ { "registryUrl": "https://jfrog.company.com/artifactory", - "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "releaseTimestamp": "2021-07-21T21:08:00.000Z", "version": "1.0.0", }, { "registryUrl": "https://jfrog.company.com/artifactory", - "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "releaseTimestamp": "2021-08-23T21:03:00.000Z", "version": "1.0.1", }, { "registryUrl": "https://jfrog.company.com/artifactory", - "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "releaseTimestamp": "2021-07-21T21:09:00.000Z", "version": "1.0.2", }, { "registryUrl": "https://jfrog.company.com/artifactory", - "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "releaseTimestamp": "2021-02-06T10:54:00.000Z", "version": "1.0.3", }, { diff --git a/lib/modules/platform/azure/azure-helper.ts b/lib/modules/platform/azure/azure-helper.ts index 3c96fc94caf5a9..efdf69a7ac63e1 100644 --- a/lib/modules/platform/azure/azure-helper.ts +++ b/lib/modules/platform/azure/azure-helper.ts @@ -9,13 +9,12 @@ import { streamToString } from '../../../util/streams'; import { getNewBranchName } from '../util'; import * as azureApi from './azure-got-wrapper'; import { WrappedExceptionSchema } from './schema'; +import { AzurePolicyTypeId } from './types'; import { getBranchNameWithoutRefsPrefix, getBranchNameWithoutRefsheadsPrefix, } from './util'; -const mergePolicyGuid = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab'; // Magic GUID for merge strategy policy configurations - export async function getRefs( repoId: string, branchName?: string, @@ -151,7 +150,11 @@ export async function getMergeMethod( const policyConfigurations = ( await ( await azureApi.policyApi() - ).getPolicyConfigurations(project, undefined, mergePolicyGuid) + ).getPolicyConfigurations( + project, + undefined, + AzurePolicyTypeId.RequireAMergeStrategy, + ) ) .filter((p) => p.settings.scope.some(isRelevantScope)) .map((p) => p.settings)[0]; diff --git a/lib/modules/platform/azure/index.spec.ts b/lib/modules/platform/azure/index.spec.ts index 73308cdfbfa98e..f7aba6d6e86af2 100644 --- a/lib/modules/platform/azure/index.spec.ts +++ b/lib/modules/platform/azure/index.spec.ts @@ -7,6 +7,10 @@ import { GitStatusState, PullRequestStatus, } from 'azure-devops-node-api/interfaces/GitInterfaces.js'; +import { + type PolicyEvaluationRecord, + PolicyEvaluationStatus, +} from 'azure-devops-node-api/interfaces/PolicyInterfaces'; import { mockDeep } from 'jest-mock-extended'; import { mocked, partial } from '../../../../test/util'; import { @@ -17,7 +21,7 @@ import type { logger as _logger } from '../../../logger'; import type * as _git from '../../../util/git'; import type * as _hostRules from '../../../util/host-rules'; import type { Platform, RepoParams } from '../types'; -import { AzurePrVote } from './types'; +import { AzurePolicyTypeId, AzurePrVote } from './types'; jest.mock('./azure-got-wrapper'); jest.mock('./azure-helper'); @@ -1898,6 +1902,151 @@ describe('modules/platform/azure/index', () => { expect(logger.warn).toHaveBeenCalled(); expect(res).toBeTrue(); }); + + it('should bypass policies with provided reason if non approved policies are bypassed', async () => { + await initRepo({ repository: 'some/repo' }); + const pullRequestIdMock = 12345; + const branchNameMock = 'test'; + const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; + const bypassReasonMock = 'Bypassed by Renovate'; + + const updatePullRequestMock = jest.fn(() => ({ + status: PullRequestStatus.Completed, + })); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getPullRequestById: jest.fn(() => ({ + lastMergeSourceCommit: lastMergeSourceCommitMock, + targetRefName: 'refs/heads/ding', + title: 'title', + })), + updatePullRequest: updatePullRequestMock, + }) as any, + ); + azureApi.policyApi.mockImplementationOnce( + () => + ({ + getPolicyEvaluations: jest.fn( + () => + [ + { + configuration: { + settings: undefined, + isEnabled: true, + isBlocking: true, + type: { + id: AzurePolicyTypeId.MinimumNumberOfReviewers, + }, + }, + status: PolicyEvaluationStatus.Queued, + }, + ] satisfies PolicyEvaluationRecord[], + ), + }) as any, + ); + + azureHelper.getMergeMethod = jest + .fn() + .mockReturnValue(GitPullRequestMergeStrategy.Squash); + + const res = await azure.mergePr({ + branchName: branchNameMock, + id: pullRequestIdMock, + strategy: 'auto', + platformOptions: { + azureBypassPolicyMinimumNumberOfReviewers: true, + azureBypassPolicyReason: bypassReasonMock, + }, + }); + + expect(updatePullRequestMock).toHaveBeenCalledWith( + { + status: PullRequestStatus.Completed, + lastMergeSourceCommit: lastMergeSourceCommitMock, + completionOptions: { + mergeStrategy: GitPullRequestMergeStrategy.Squash, + deleteSourceBranch: true, + mergeCommitMessage: 'title', + bypassPolicy: true, + bypassReason: bypassReasonMock, + }, + }, + '1', + pullRequestIdMock, + ); + expect(res).toBeTrue(); + }); + + it('should not bypass policies if enabled but some policies did not pass', async () => { + await initRepo({ repository: 'some/repo' }); + const pullRequestIdMock = 12345; + const branchNameMock = 'test'; + const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; + + const updatePullRequestMock = jest.fn(() => ({ + status: PullRequestStatus.Completed, + })); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getPullRequestById: jest.fn(() => ({ + lastMergeSourceCommit: lastMergeSourceCommitMock, + targetRefName: 'refs/heads/ding', + title: 'title', + })), + updatePullRequest: updatePullRequestMock, + }) as any, + ); + azureApi.policyApi.mockImplementationOnce( + () => + ({ + getPolicyEvaluations: jest.fn( + () => + [ + { + configuration: { + settings: undefined, + isEnabled: true, + isBlocking: true, + type: { + id: AzurePolicyTypeId.MinimumNumberOfReviewers, + }, + }, + status: PolicyEvaluationStatus.Queued, + }, + ] satisfies PolicyEvaluationRecord[], + ), + }) as any, + ); + + azureHelper.getMergeMethod = jest + .fn() + .mockReturnValue(GitPullRequestMergeStrategy.Squash); + + const res = await azure.mergePr({ + branchName: branchNameMock, + id: pullRequestIdMock, + strategy: 'auto', + }); + + expect(updatePullRequestMock).toHaveBeenCalledWith( + { + status: PullRequestStatus.Completed, + lastMergeSourceCommit: lastMergeSourceCommitMock, + completionOptions: { + mergeStrategy: GitPullRequestMergeStrategy.Squash, + deleteSourceBranch: true, + mergeCommitMessage: 'title', + bypassPolicy: undefined, + bypassReason: undefined, + }, + }, + '1', + pullRequestIdMock, + ); + expect(res).toBeTrue(); + }); }); describe('deleteLabel()', () => { diff --git a/lib/modules/platform/azure/index.ts b/lib/modules/platform/azure/index.ts index 0fd6bb8ccefcf1..9bc7aa5097a281 100644 --- a/lib/modules/platform/azure/index.ts +++ b/lib/modules/platform/azure/index.ts @@ -11,6 +11,7 @@ import { GitStatusState, PullRequestStatus, } from 'azure-devops-node-api/interfaces/GitInterfaces.js'; +import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; import { REPOSITORY_ARCHIVED, REPOSITORY_EMPTY, @@ -46,7 +47,7 @@ import { smartTruncate } from '../utils/pr-body'; import * as azureApi from './azure-got-wrapper'; import * as azureHelper from './azure-helper'; import type { AzurePr } from './types'; -import { AzurePrVote } from './types'; +import { AzurePolicyTypeId, AzurePrVote } from './types'; import { getBranchNameWithoutRefsheadsPrefix, getGitStatusContextCombinedName, @@ -767,9 +768,11 @@ export async function mergePr({ branchName, id: pullRequestId, strategy, + platformOptions, }: MergePRConfig): Promise { logger.debug(`mergePr(${pullRequestId}, ${branchName!})`); const azureApiGit = await azureApi.gitApi(); + const azurePolicyApi = await azureApi.policyApi(); let pr = await azureApiGit.getPullRequestById(pullRequestId, config.project); @@ -777,6 +780,47 @@ export async function mergePr({ strategy === 'auto' ? await getMergeStrategy(pr.targetRefName!) : mapMergeStrategy(strategy); + + let bypassPolicy: boolean | undefined; + + const bypassPolicyTypes = new Set( + [ + platformOptions?.ignoreTests ? AzurePolicyTypeId.Build : [], + platformOptions?.azureBypassPolicyRequiredReviewers + ? AzurePolicyTypeId.RequiredReviewers + : [], + platformOptions?.azureBypassPolicyMinimumNumberOfReviewers + ? AzurePolicyTypeId.MinimumNumberOfReviewers + : [], + platformOptions?.azureBypassPolicyWorkItemLinking + ? AzurePolicyTypeId.WorkItemLinking + : [], + platformOptions?.azureBypassPolicyTypeUuids ?? [], + ].flat(), + ); + + if (bypassPolicyTypes.size > 0) { + const artifactId = `vstfs:///CodeReview/CodeReviewId/${config.project}/${pullRequestId}`; + const policyEvaluations = await azurePolicyApi.getPolicyEvaluations( + config.project, + artifactId, + ); + + // only use bypass if all required policies are in approved state + bypassPolicy = policyEvaluations + .filter( + (policy) => + policy.configuration?.isEnabled && + policy.configuration.isBlocking && + !bypassPolicyTypes.has(policy.configuration.type?.id ?? ''), + ) + .every((policy) => policy.status === PolicyEvaluationStatus.Approved); + } + + const bypassReason = bypassPolicy + ? (platformOptions?.azureBypassPolicyReason ?? 'Renovate automerge') + : undefined; + const objToUpdate: GitPullRequest = { status: PullRequestStatus.Completed, lastMergeSourceCommit: pr.lastMergeSourceCommit, @@ -784,6 +828,8 @@ export async function mergePr({ mergeStrategy, deleteSourceBranch: true, mergeCommitMessage: pr.title, + bypassPolicy, + bypassReason, }, }; @@ -795,7 +841,7 @@ export async function mergePr({ pr.lastMergeSourceCommit?.commitId } using mergeStrategy ${mergeStrategy} (${ GitPullRequestMergeStrategy[mergeStrategy] - })`, + })${bypassPolicy ? ' and bypassPolicies' : ''}`, ); try { diff --git a/lib/modules/platform/azure/types.ts b/lib/modules/platform/azure/types.ts index f58a6e5d9fb0a5..689c9eadf28ead 100644 --- a/lib/modules/platform/azure/types.ts +++ b/lib/modules/platform/azure/types.ts @@ -11,3 +11,11 @@ export const AzurePrVote = { ApprovedWithSuggestions: 5, Approved: 10, } as const; + +export const AzurePolicyTypeId = { + RequiredReviewers: 'fd2167ab-b0be-447a-8ec8-39368250530e', + RequireAMergeStrategy: 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab', + MinimumNumberOfReviewers: 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd', + Build: '0609b952-1397-4640-95ec-e00a01b2c241', + WorkItemLinking: '40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e', +} as const; diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index 81a8a84994fcb7..ba4b0df6209393 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -106,6 +106,12 @@ export type PlatformPrOptions = { gitLabIgnoreApprovals?: boolean; usePlatformAutomerge?: boolean; forkModeDisallowMaintainerEdits?: boolean; + ignoreTests?: boolean; + azureBypassPolicyRequiredReviewers?: boolean; + azureBypassPolicyMinimumNumberOfReviewers?: boolean; + azureBypassPolicyWorkItemLinking?: boolean; + azureBypassPolicyTypeUuids?: string[]; + azureBypassPolicyReason?: string; }; export interface CreatePRConfig { @@ -180,6 +186,7 @@ export interface MergePRConfig { branchName?: string; id: number; strategy?: MergeStrategy; + platformOptions?: PlatformPrOptions; } export interface EnsureCommentConfig { number: number; diff --git a/lib/workers/repository/update/pr/automerge.ts b/lib/workers/repository/update/pr/automerge.ts index 2ace410741b3b8..36912bbb241466 100644 --- a/lib/workers/repository/update/pr/automerge.ts +++ b/lib/workers/repository/update/pr/automerge.ts @@ -11,6 +11,7 @@ import { scm } from '../../../../modules/platform/scm'; import type { BranchConfig } from '../../../types'; import { isScheduledNow } from '../branch/schedule'; import { resolveBranchStatus } from '../branch/status-checks'; +import { getPlatformPrOptions } from './index'; export type PrAutomergeBlockReason = | 'BranchModified' @@ -137,6 +138,7 @@ export async function checkAutoMerge( branchName, id: pr.number, strategy: automergeStrategy, + platformOptions: getPlatformPrOptions(config), }); if (res) { logger.info({ pr: pr.number, prTitle: pr.title }, 'PR automerged'); diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index aa6817daf31a35..6d3eb9fdfc7680 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -62,6 +62,14 @@ export function getPlatformPrOptions( bbUseDefaultReviewers: !!config.bbUseDefaultReviewers, gitLabIgnoreApprovals: !!config.gitLabIgnoreApprovals, forkModeDisallowMaintainerEdits: !!config.forkModeDisallowMaintainerEdits, + ignoreTests: !!config.ignoreTests, + azureBypassPolicyRequiredReviewers: + !!config.azureBypassPolicyRequiredReviewers, + azureBypassPolicyMinimumNumberOfReviewers: + !!config.azureBypassPolicyMinimumNumberOfReviewers, + azureBypassPolicyWorkItemLinking: !!config.azureBypassPolicyWorkItemLinking, + azureBypassPolicyTypeUuids: config.azureBypassPolicyTypeUuids ?? [], + azureBypassPolicyReason: config.azureBypassPolicyReason, usePlatformAutomerge, }; }