From a40d1f721d59b6bf40ef8becf1e9e9bb48667bb9 Mon Sep 17 00:00:00 2001
From: Zachary Leighton <zleight1@gmail.com>
Date: Sun, 4 Oct 2020 09:34:21 +0300
Subject: [PATCH] feat(azure): implemented azure branch status checks for
 automerge (#7398)

* feat(azure): implemented azure branch status checks for automerge

Implemented checks for branch statuses for azure, using the latest branch gitStatusState to determine if it's pending, failing or successful. This is required to properly automerge, as it was always pending prior.
Added tests for scenarios with various results and updated relevant existing tests.

fixes #7392

* feat(azure): finished implementing status checks and also setting of a branch status

Finished implementation according to how the other platforms work as much as possible.
Implemented the setting of a branch status, using the createCommitStatus api from azure.
Created util classes to handle the way azure translates named contexts with slashes to objects and reverse.
Tests for above changes.

Closes #7392

* test(azure): add test for uncovered lines to bring coverage back to 100

* chore(azure): fix the log message to be accurate and in the right spot and add a trace log to the end of the set status call

* Update lib/platform/azure/index.ts

Co-authored-by: Jamie Magee <JamieMagee@users.noreply.github.com>

* Update lib/platform/azure/index.ts

Co-authored-by: Jamie Magee <JamieMagee@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 lib/platform/azure/index.spec.ts | 242 +++++++++++++++++++++++++++++--
 lib/platform/azure/index.ts      |  97 +++++++++++--
 lib/platform/azure/util.spec.ts  |  55 +++++++
 lib/platform/azure/util.ts       |  33 +++++
 4 files changed, 405 insertions(+), 22 deletions(-)

diff --git a/lib/platform/azure/index.spec.ts b/lib/platform/azure/index.spec.ts
index 0860c238dc..66d746e540 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 55411c32f5..49f60983b2 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 2fc2c8dc19..8f4d7e0378 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 5863144f0e..ecd757a54b 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 {
-- 
GitLab