diff --git a/lib/platform/azure/index.spec.ts b/lib/platform/azure/index.spec.ts index 0860c238dc8f048e3a9d09a0c5507a0fde5c595e..66d746e5404bc3ebc6c32128da6bf65300df6f34 100644 --- a/lib/platform/azure/index.spec.ts +++ b/lib/platform/azure/index.spec.ts @@ -1,5 +1,8 @@ import is from '@sindresorhus/is'; -import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { + GitStatusState, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; import { BranchStatus, PrState } from '../../types'; import * as _git from '../../util/git'; import * as _hostRules from '../../util/host-rules'; @@ -324,7 +327,149 @@ describe('platform/azure', () => { expect(pr).toMatchSnapshot(); }); }); + describe('getBranchStatusCheck(branchName, context)', () => { + it('should return green if status is succeeded', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.Succeeded, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBe(BranchStatus.green); + }); + it('should return green if status is not applicable', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.NotApplicable, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBe(BranchStatus.green); + }); + it('should return red if status is failed', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.Failed, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBe(BranchStatus.red); + }); + it('should return red if context status is error', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.Error, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toEqual(BranchStatus.red); + }); + it('should return yellow if status is pending', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.Pending, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBe(BranchStatus.yellow); + }); + it('should return yellow if status is not set', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.NotSet, + context: { genre: 'a-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBe(BranchStatus.yellow); + }); + it('should return null if status not found', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [ + { + state: GitStatusState.Pending, + context: { genre: 'another-genre', name: 'a-name' }, + }, + ]), + } as any) + ); + const res = await azure.getBranchStatusCheck( + 'somebranch', + 'a-genre/a-name' + ); + expect(res).toBeNull(); + }); + }); describe('getBranchStatus(branchName, requiredStatusChecks)', () => { it('return success if requiredStatusChecks null', async () => { await initRepo('some-repo'); @@ -341,7 +486,8 @@ describe('platform/azure', () => { azureApi.gitApi.mockImplementationOnce( () => ({ - getBranch: jest.fn(() => ({ aheadCount: 0 })), + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [{ state: GitStatusState.Succeeded }]), } as any) ); const res = await azure.getBranchStatus('somebranch', []); @@ -352,7 +498,32 @@ describe('platform/azure', () => { azureApi.gitApi.mockImplementationOnce( () => ({ - getBranch: jest.fn(() => ({ aheadCount: 123 })), + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [{ state: GitStatusState.Error }]), + } as any) + ); + const res = await azure.getBranchStatus('somebranch', []); + expect(res).toEqual(BranchStatus.red); + }); + it('should pass through pending', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => [{ state: GitStatusState.Pending }]), + } as any) + ); + const res = await azure.getBranchStatus('somebranch', []); + expect(res).toEqual(BranchStatus.yellow); + }); + it('should fall back to yellow if no statuses returned', async () => { + await initRepo({ repository: 'some/repo' }); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + getStatuses: jest.fn(() => []), } as any) ); const res = await azure.getBranchStatus('somebranch', []); @@ -769,18 +940,71 @@ describe('platform/azure', () => { }); }); - describe('Not supported by Azure DevOps (yet!)', () => { - it('setBranchStatus', async () => { - const res = await azure.setBranchStatus({ + describe('setBranchStatus', () => { + it('should build and call the create status api properly', async () => { + await initRepo({ repository: 'some/repo' }); + const createCommitStatusMock = jest.fn(); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + createCommitStatus: createCommitStatusMock, + } as any) + ); + await azure.setBranchStatus({ branchName: 'test', context: 'test', description: 'test', state: BranchStatus.yellow, - url: 'test', + url: 'test.com', }); - expect(res).toBeUndefined(); + expect(createCommitStatusMock).toHaveBeenCalledWith( + { + context: { + genre: undefined, + name: 'test', + }, + description: 'test', + state: GitStatusState.Pending, + targetUrl: 'test.com', + }, + 'abcd1234', + '1' + ); }); - + it('should build and call the create status api properly with a complex context', async () => { + await initRepo({ repository: 'some/repo' }); + const createCommitStatusMock = jest.fn(); + azureApi.gitApi.mockImplementationOnce( + () => + ({ + getBranch: jest.fn(() => ({ commit: { commitId: 'abcd1234' } })), + createCommitStatus: createCommitStatusMock, + } as any) + ); + await azure.setBranchStatus({ + branchName: 'test', + context: 'renovate/artifact/test', + description: 'test', + state: BranchStatus.green, + url: 'test.com', + }); + expect(createCommitStatusMock).toHaveBeenCalledWith( + { + context: { + genre: 'renovate/artifact', + name: 'test', + }, + description: 'test', + state: GitStatusState.Succeeded, + targetUrl: 'test.com', + }, + 'abcd1234', + '1' + ); + }); + }); + describe('Not supported by Azure DevOps (yet!)', () => { it('mergePr', async () => { const res = await azure.mergePr(0, undefined); expect(res).toBe(false); diff --git a/lib/platform/azure/index.ts b/lib/platform/azure/index.ts index 55411c32f5b818f703b3d51ccaca4780514fdee7..49f60983b2cb29005438dab9b8c4f281faa68774 100644 --- a/lib/platform/azure/index.ts +++ b/lib/platform/azure/index.ts @@ -3,6 +3,8 @@ import { GitPullRequest, GitPullRequestCommentThread, GitPullRequestMergeStrategy, + GitStatus, + GitStatusState, PullRequestStatus, } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { REPOSITORY_EMPTY } from '../../constants/error-messages'; @@ -35,6 +37,8 @@ import * as azureHelper from './azure-helper'; import { AzurePr } from './types'; import { getBranchNameWithoutRefsheadsPrefix, + getGitStatusContextCombinedName, + getGitStatusContextFromCombinedName, getNewBranchName, getRenovatePRFormat, } from './util'; @@ -267,20 +271,43 @@ export async function getBranchPr(branchName: string): Promise<Pr | null> { return existingPr ? getPr(existingPr.number) : null; } -export async function getBranchStatusCheck( - branchName: string, - context: string -): Promise<BranchStatus> { - logger.trace(`getBranchStatusCheck(${branchName}, ${context})`); +async function getStatusCheck(branchName: string): Promise<GitStatus[]> { const azureApiGit = await azureApi.gitApi(); const branch = await azureApiGit.getBranch( config.repoId, getBranchNameWithoutRefsheadsPrefix(branchName) ); - if (branch.aheadCount === 0) { - return BranchStatus.green; + // only grab the latest statuses, it will group any by context + return azureApiGit.getStatuses( + branch.commit.commitId, + config.repoId, + undefined, + undefined, + undefined, + true + ); +} + +const azureToRenovateStatusMapping: Record<GitStatusState, BranchStatus> = { + [GitStatusState.Succeeded]: BranchStatus.green, + [GitStatusState.NotApplicable]: BranchStatus.green, + [GitStatusState.NotSet]: BranchStatus.yellow, + [GitStatusState.Pending]: BranchStatus.yellow, + [GitStatusState.Error]: BranchStatus.red, + [GitStatusState.Failed]: BranchStatus.red, +}; + +export async function getBranchStatusCheck( + branchName: string, + context: string +): Promise<BranchStatus | null> { + const res = await getStatusCheck(branchName); + for (const check of res) { + if (getGitStatusContextCombinedName(check.context) === context) { + return azureToRenovateStatusMapping[check.state] || BranchStatus.yellow; + } } - return BranchStatus.yellow; + return null; } export async function getBranchStatus( @@ -297,8 +324,29 @@ export async function getBranchStatus( logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`); return BranchStatus.red; } - const branchStatusCheck = await getBranchStatusCheck(branchName, null); - return branchStatusCheck; + const statuses = await getStatusCheck(branchName); + logger.debug({ branch: branchName, statuses }, 'branch status check result'); + if (!statuses.length) { + logger.debug('empty branch status check result = returning "pending"'); + return BranchStatus.yellow; + } + const noOfFailures = statuses.filter( + (status: GitStatus) => + status.state === GitStatusState.Error || + status.state === GitStatusState.Failed + ).length; + if (noOfFailures) { + return BranchStatus.red; + } + const noOfPending = statuses.filter( + (status: GitStatus) => + status.state === GitStatusState.NotSet || + status.state === GitStatusState.Pending + ).length; + if (noOfPending) { + return BranchStatus.yellow; + } + return BranchStatus.green; } export async function createPr({ @@ -489,7 +537,14 @@ export async function ensureCommentRemoval({ } } -export function setBranchStatus({ +const renovateToAzureStatusMapping: Record<BranchStatus, GitStatusState> = { + [BranchStatus.green]: [GitStatusState.Succeeded], + [BranchStatus.green]: GitStatusState.Succeeded, + [BranchStatus.yellow]: GitStatusState.Pending, + [BranchStatus.red]: GitStatusState.Failed, +}; + +export async function setBranchStatus({ branchName, context, description, @@ -497,9 +552,25 @@ export function setBranchStatus({ url: targetUrl, }: BranchStatusConfig): Promise<void> { logger.debug( - `setBranchStatus(${branchName}, ${context}, ${description}, ${state}, ${targetUrl}) - Not supported by Azure DevOps (yet!)` + `setBranchStatus(${branchName}, ${context}, ${description}, ${state}, ${targetUrl})` ); - return Promise.resolve(); + const azureApiGit = await azureApi.gitApi(); + const branch = await azureApiGit.getBranch( + config.repoId, + getBranchNameWithoutRefsheadsPrefix(branchName) + ); + const statusToCreate: GitStatus = { + description, + context: getGitStatusContextFromCombinedName(context), + state: renovateToAzureStatusMapping[state], + targetUrl, + }; + await azureApiGit.createCommitStatus( + statusToCreate, + branch.commit.commitId, + config.repoId + ); + logger.trace(`Created commit status of ${state} on branch ${branchName}`); } export function mergePr(pr: number, branchName: string): Promise<boolean> { diff --git a/lib/platform/azure/util.spec.ts b/lib/platform/azure/util.spec.ts index 2fc2c8dc1974a2148ceb955df6c97e6e955a8cb3..8f4d7e0378c1f468335005af6fe8f9dc807b091d 100644 --- a/lib/platform/azure/util.spec.ts +++ b/lib/platform/azure/util.spec.ts @@ -1,5 +1,7 @@ import { getBranchNameWithoutRefsheadsPrefix, + getGitStatusContextCombinedName, + getGitStatusContextFromCombinedName, getNewBranchName, getRenovatePRFormat, } from './util'; @@ -16,6 +18,59 @@ describe('platform/azure/helpers', () => { }); }); + describe('getGitStatusContextCombinedName', () => { + it('should return undefined if null context passed', () => { + const contextName = getGitStatusContextCombinedName(null); + expect(contextName).toBeUndefined(); + }); + it('should combine valid genre and name with slash', () => { + const contextName = getGitStatusContextCombinedName({ + genre: 'my-genre', + name: 'status-name', + }); + expect(contextName).toMatch('my-genre/status-name'); + }); + it('should combine valid empty genre and name without a slash', () => { + const contextName = getGitStatusContextCombinedName({ + genre: undefined, + name: 'status-name', + }); + expect(contextName).toMatch('status-name'); + }); + }); + + describe('getGitStatusContextFromCombinedName', () => { + it('should return undefined if null context passed', () => { + const context = getGitStatusContextFromCombinedName(null); + expect(context).toBeUndefined(); + }); + it('should parse valid genre and name with slash', () => { + const context = getGitStatusContextFromCombinedName( + 'my-genre/status-name' + ); + expect(context).toEqual({ + genre: 'my-genre', + name: 'status-name', + }); + }); + it('should parse valid genre and name with multiple slashes', () => { + const context = getGitStatusContextFromCombinedName( + 'my-genre/sub-genre/status-name' + ); + expect(context).toEqual({ + genre: 'my-genre/sub-genre', + name: 'status-name', + }); + }); + it('should parse valid empty genre and name without a slash', () => { + const context = getGitStatusContextFromCombinedName('status-name'); + expect(context).toEqual({ + genre: undefined, + name: 'status-name', + }); + }); + }); + describe('getBranchNameWithoutRefsheadsPrefix', () => { it('should be renamed', () => { const res = getBranchNameWithoutRefsheadsPrefix('refs/heads/testBB'); diff --git a/lib/platform/azure/util.ts b/lib/platform/azure/util.ts index 5863144f0e527fd8813c379dea4ac8071030a7f3..ecd757a54b5fb10b481ee1e13324f67e520ff3d2 100644 --- a/lib/platform/azure/util.ts +++ b/lib/platform/azure/util.ts @@ -1,5 +1,6 @@ import { GitPullRequest, + GitStatusContext, PullRequestAsyncStatus, PullRequestStatus, } from 'azure-devops-node-api/interfaces/GitInterfaces'; @@ -14,6 +15,38 @@ export function getNewBranchName(branchName?: string): string { return branchName; } +export function getGitStatusContextCombinedName( + context: GitStatusContext +): string | undefined { + if (!context) { + return undefined; + } + const combinedName = `${context.genre ? `${context.genre}/` : ''}${ + context.name + }`; + logger.trace(`Got combined context name of ${combinedName}`); + return combinedName; +} + +export function getGitStatusContextFromCombinedName( + context: string +): GitStatusContext | undefined { + if (!context) { + return undefined; + } + let name = context; + let genre; + const lastSlash = context.lastIndexOf('/'); + if (lastSlash > 0) { + name = context.substr(lastSlash + 1); + genre = context.substr(0, lastSlash); + } + return { + genre, + name, + }; +} + export function getBranchNameWithoutRefsheadsPrefix( branchPath: string ): string | undefined {