From c9335d5bf6074830cd088374acfea26a696151f0 Mon Sep 17 00:00:00 2001
From: JYC <jycouet@gmail.com>
Date: Sun, 12 Nov 2017 10:26:53 +0100
Subject: [PATCH] Add VSTS support (#1049)

This PR adds support for Microsoft's [Visual Studio Team Services](https://www.visualstudio.com/team-services/) platform (in addition to existing GitHub and GitLab support).

Closes #571
---
 lib/config/index.js                           |  11 +
 lib/platform/index.js                         |   3 +
 lib/platform/vsts/index.js                    | 486 ++++++++++++++
 lib/platform/vsts/vsts-got-wrapper.js         |  19 +
 lib/platform/vsts/vsts-helper.js              | 256 +++++++
 lib/workers/global/index.js                   |   1 +
 lib/workers/pr/index.js                       |   7 +
 package.json                                  |   6 +-
 readme.md                                     |   6 +-
 test/config/index.spec.js                     |  39 ++
 .../platform/__snapshots__/index.spec.js.snap |  36 +-
 test/platform/index.spec.js                   |  24 +-
 .../vsts/__snapshots__/index.spec.js.snap     | 185 +++++
 .../vsts-got-wrapper.spec.js.snap             |  57 ++
 .../__snapshots__/vsts-helper.spec.js.snap    | 128 ++++
 test/platform/vsts/index.spec.js              | 631 ++++++++++++++++++
 test/platform/vsts/vsts-got-wrapper.spec.js   |  46 ++
 test/platform/vsts/vsts-helper.spec.js        | 295 ++++++++
 .../pr/__snapshots__/index.spec.js.snap       |  26 +
 test/workers/pr/index.spec.js                 |   9 +
 test/workers/repository/init/apis.spec.js     |  12 +
 yarn.lock                                     | 304 +++++----
 22 files changed, 2461 insertions(+), 126 deletions(-)
 create mode 100644 lib/platform/vsts/index.js
 create mode 100644 lib/platform/vsts/vsts-got-wrapper.js
 create mode 100644 lib/platform/vsts/vsts-helper.js
 create mode 100644 test/platform/vsts/__snapshots__/index.spec.js.snap
 create mode 100644 test/platform/vsts/__snapshots__/vsts-got-wrapper.spec.js.snap
 create mode 100644 test/platform/vsts/__snapshots__/vsts-helper.spec.js.snap
 create mode 100644 test/platform/vsts/index.spec.js
 create mode 100644 test/platform/vsts/vsts-got-wrapper.spec.js
 create mode 100644 test/platform/vsts/vsts-helper.spec.js

diff --git a/lib/config/index.js b/lib/config/index.js
index a6a1bc0f6c..97c850a381 100644
--- a/lib/config/index.js
+++ b/lib/config/index.js
@@ -1,5 +1,6 @@
 const githubApi = require('../platform/github');
 const gitlabApi = require('../platform/gitlab');
+const vstsApi = require('../platform/vsts');
 
 const definitions = require('./definitions');
 
@@ -62,6 +63,10 @@ async function parseConfigs(env, argv) {
     if (!config.token && !env.GITLAB_TOKEN) {
       throw new Error('You need to supply a GitLab token.');
     }
+  } else if (config.platform === 'vsts') {
+    if (!config.token && !env.VSTS_TOKEN) {
+      throw new Error('You need to supply a VSTS token.');
+    }
   } else {
     throw new Error(`Unsupported platform: ${config.platform}.`);
   }
@@ -80,6 +85,12 @@ async function parseConfigs(env, argv) {
         config.token,
         config.endpoint
       );
+    } else if (config.platform === 'vsts') {
+      logger.info('Autodiscovering vsts repositories');
+      config.repositories = await vstsApi.getRepos(
+        config.token,
+        config.endpoint
+      );
     }
     if (!config.repositories || config.repositories.length === 0) {
       // Soft fail (no error thrown) if no accessible repositories
diff --git a/lib/platform/index.js b/lib/platform/index.js
index 1af5e29404..711d984ccc 100644
--- a/lib/platform/index.js
+++ b/lib/platform/index.js
@@ -1,11 +1,14 @@
 const github = require('./github');
 const gitlab = require('./gitlab');
+const vsts = require('./vsts');
 
 function initPlatform(val) {
   if (val === 'github') {
     global.platform = github;
   } else if (val === 'gitlab') {
     global.platform = gitlab;
+  } else if (val === 'vsts') {
+    global.platform = vsts;
   }
 }
 
diff --git a/lib/platform/vsts/index.js b/lib/platform/vsts/index.js
new file mode 100644
index 0000000000..3d1d5d3847
--- /dev/null
+++ b/lib/platform/vsts/index.js
@@ -0,0 +1,486 @@
+// @ts-nocheck //because of logger, we can't ts-check
+const vstsHelper = require('./vsts-helper');
+const gitApi = require('./vsts-got-wrapper');
+
+const config = {};
+
+module.exports = {
+  // Initialization
+  getRepos,
+  initRepo,
+  setBaseBranch,
+  // Search
+  getFileList,
+  // Branch
+  branchExists,
+  getAllRenovateBranches,
+  isBranchStale,
+  getBranchPr,
+  getBranchStatus,
+  getBranchStatusCheck,
+  setBranchStatus,
+  deleteBranch,
+  mergeBranch,
+  getBranchLastCommitTime,
+  // issue
+  addAssignees,
+  addReviewers,
+  // Comments
+  ensureComment,
+  ensureCommentRemoval,
+  // PR
+  findPr,
+  createPr,
+  getPr,
+  getPrFiles,
+  updatePr,
+  mergePr,
+  // file
+  commitFilesToBranch,
+  getFile,
+  // Commits
+  getCommitMessages,
+};
+
+async function getRepos(token, endpoint) {
+  logger.debug('getRepos(token, endpoint)');
+  vstsHelper.setTokenAndEndpoint(token, endpoint);
+  const repos = await gitApi().getRepositories();
+  return repos.map(repo => repo.name);
+}
+
+async function initRepo(repoName, token, endpoint) {
+  logger.debug(`initRepo("${repoName}")`);
+  vstsHelper.setTokenAndEndpoint(token, endpoint);
+  config.repoName = repoName;
+  config.fileList = null;
+  config.prList = null;
+  const repos = await gitApi().getRepositories();
+  const repo = repos.filter(c => c.name === repoName)[0];
+  logger.debug({ repositoryDetails: repo }, 'Repository details');
+  config.repoId = repo.id;
+  config.privateRepo = true;
+  config.isFork = false;
+  config.owner = '?owner?';
+  logger.debug(`${repoName} owner = ${config.owner}`);
+  // Use default branch as PR target unless later overridden
+  config.defaultBranch = repo.defaultBranch;
+  config.baseBranch = config.defaultBranch;
+  logger.debug(`${repoName} default branch = ${config.defaultBranch}`);
+  config.baseCommitSHA = await getBranchCommit(config.baseBranch);
+
+  // Todo VSTS: Get Merge method
+  config.mergeMethod = 'merge';
+  // if (res.body.allow_rebase_merge) {
+  //   config.mergeMethod = 'rebase';
+  // } else if (res.body.allow_squash_merge) {
+  //   config.mergeMethod = 'squash';
+  // } else if (res.body.allow_merge_commit) {
+  //   config.mergeMethod = 'merge';
+  // } else {
+  //   logger.debug('Could not find allowed merge methods for repo');
+  // }
+
+  // Todo VSTS: Get getBranchProtection
+  config.repoForceRebase = false;
+  // try {
+  //   const branchProtection = await getBranchProtection(config.baseBranch);
+  //   if (branchProtection.strict) {
+  //     logger.debug('Repo has branch protection and needs PRs up-to-date');
+  //     config.repoForceRebase = true;
+  //   } else {
+  //     logger.debug(
+  //       'Repo has branch protection but does not require up-to-date'
+  //     );
+  //   }
+  // } catch (err) {
+  //   if (err.statusCode === 404) {
+  //     logger.debug('Repo has no branch protection');
+  //   } else if (err.statusCode === 403) {
+  //     logger.debug('Do not have permissions to detect branch protection');
+  //   } else {
+  //     throw err;
+  //   }
+  // }
+  return config;
+}
+
+async function setBaseBranch(branchName) {
+  if (branchName) {
+    logger.debug(`Setting baseBranch to ${branchName}`);
+    config.baseBranch = branchName;
+    config.baseCommitSHA = await getBranchCommit(config.baseBranch);
+  }
+}
+
+async function getBranchCommit(fullBranchName) {
+  const commit = await gitApi().getBranch(
+    config.repoId,
+    vstsHelper.getBranchNameWithoutRefsheadsPrefix(fullBranchName)
+  );
+  return commit.commit.commitId;
+}
+
+async function getCommitMessages() {
+  logger.debug('getCommitMessages');
+  try {
+    // @ts-ignore
+    const res = await gitApi().getCommits(config.repoId);
+    const msg = res.map(commit => commit.comment);
+    return msg;
+  } catch (err) {
+    logger.error({ err }, `getCommitMessages error`);
+    return [];
+  }
+}
+
+async function getFile(filePath, branchName = config.baseBranch) {
+  logger.trace(`getFile(filePath=${filePath}, branchName=${branchName})`);
+  const f = await vstsHelper.getFile(
+    config.repoId,
+    config.name,
+    filePath,
+    branchName
+  );
+  return f;
+}
+
+async function findPr(branchName, prTitle, state = 'all') {
+  logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`);
+  let prsFiltered = [];
+  try {
+    const prs = await gitApi().getPullRequests(config.repoId, null);
+
+    prsFiltered = prs.filter(
+      item => item.sourceRefName === vstsHelper.getNewBranchName(branchName)
+    );
+
+    if (prTitle) {
+      prsFiltered = prsFiltered.filter(item => item.title === prTitle);
+    }
+
+    // update format
+    prsFiltered = prsFiltered.map(item => vstsHelper.getRenovatePRFormat(item));
+
+    switch (state) {
+      case 'all':
+        // no more filter needed, we can go further...
+        break;
+      case 'open':
+        prsFiltered = prsFiltered.filter(item => item.isClosed === false);
+        break;
+      case 'closed':
+        prsFiltered = prsFiltered.filter(item => item.isClosed === true);
+        break;
+      default:
+        logger.error(`VSTS unmanaged state of a PR (${state})`);
+        break;
+    }
+  } catch (error) {
+    logger.error('findPr ' + error);
+  }
+  if (prsFiltered.length === 0) {
+    return null;
+  }
+  return prsFiltered[0];
+}
+
+async function getFileList(branchName = config.baseBranch) {
+  logger.trace(`getFileList('${branchName})'`);
+  try {
+    if (config.fileList) {
+      return config.fileList;
+    }
+    const items = await gitApi().getItems(
+      config.repoId,
+      null,
+      null,
+      120, // full
+      null,
+      null,
+      null,
+      false
+    );
+    config.fileList = items
+      .filter(c => !c.isFolder)
+      .map(c => c.path.substring(1, c.path.length))
+      .sort();
+    return config.fileList;
+  } catch (error) {
+    logger.error(`getFileList('${branchName})'`);
+    return [];
+  }
+}
+
+async function commitFilesToBranch(
+  branchName,
+  files,
+  message,
+  parentBranch = config.baseBranch
+) {
+  logger.debug(
+    `commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`
+  );
+
+  // Create the new Branch
+  let branchRef = await vstsHelper.getVSTSBranchObj(
+    config.repoId,
+    branchName,
+    parentBranch
+  );
+
+  // create commits
+  for (const file of files) {
+    const isBranchExisting = await branchExists(`refs/heads/${branchName}`);
+    if (isBranchExisting) {
+      branchRef = await vstsHelper.getVSTSBranchObj(
+        config.repoId,
+        branchName,
+        branchName
+      );
+    }
+
+    const commit = await vstsHelper.getVSTSCommitObj(
+      message,
+      file.name,
+      file.contents,
+      config.repoId,
+      config.name,
+      parentBranch
+    );
+    await gitApi().createPush(
+      // @ts-ignore
+      {
+        commits: [commit],
+        refUpdates: [branchRef],
+      },
+      config.repoId
+    );
+  }
+}
+
+async function branchExists(branchName) {
+  logger.debug(`Checking if branch exists: ${branchName}`);
+
+  const branchNameToUse = !branchName.startsWith('refs/heads/')
+    ? `refs/heads/${branchName}`
+    : branchName;
+
+  const branchs = await vstsHelper.getRefs(config.repoId, branchNameToUse);
+  if (branchs.length === 0) {
+    return false;
+  }
+  return true;
+}
+
+async function getBranchPr(branchName) {
+  logger.debug(`getBranchPr(${branchName})`);
+  const existingPr = await findPr(branchName, null, 'open');
+  return existingPr ? getPr(existingPr.pullRequestId) : null;
+}
+
+async function getBranchStatus(branchName, requiredStatusChecks) {
+  logger.debug(`getBranchStatus(${branchName})`);
+  if (!requiredStatusChecks) {
+    // null means disable status checks, so it always succeeds
+    return 'success';
+  }
+  if (requiredStatusChecks.length) {
+    // This is Unsupported
+    logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`);
+    return 'failed';
+  }
+  const branchStatusCheck = await getBranchStatusCheck(branchName);
+  return branchStatusCheck;
+}
+
+async function getBranchStatusCheck(branchName, context) {
+  logger.trace(`getBranchStatusCheck(${branchName}, ${context})`);
+  const branch = await gitApi().getBranch(
+    config.repoId,
+    vstsHelper.getBranchNameWithoutRefsheadsPrefix(branchName)
+  );
+  if (branch.aheadCount === 0) {
+    return 'success';
+  }
+  return 'pending';
+}
+
+async function getPr(pullRequestId) {
+  logger.debug(`getPr(${pullRequestId})`);
+  if (!pullRequestId) {
+    return null;
+  }
+  const prs = await gitApi().getPullRequests(config.repoId, null);
+  const vstsPr = prs.filter(item => item.pullRequestId === pullRequestId);
+  if (vstsPr.length === 0) {
+    return null;
+  }
+  logger.debug(`pr: (${vstsPr[0]})`);
+  const pr = vstsHelper.getRenovatePRFormat(vstsPr[0]);
+  return pr;
+}
+
+async function createPr(branchName, title, body, labels, useDefaultBranch) {
+  const sourceRefName = vstsHelper.getNewBranchName(branchName);
+  const targetRefName = useDefaultBranch
+    ? config.defaultBranch
+    : config.baseBranch;
+  const description = vstsHelper.max4000Chars(body);
+  const pr = await gitApi().createPullRequest(
+    {
+      sourceRefName,
+      targetRefName,
+      title,
+      description,
+    },
+    config.repoId
+  );
+  return vstsHelper.getRenovatePRFormat(pr);
+}
+
+async function updatePr(prNo, title, body) {
+  logger.debug(`updatePr(${prNo}, ${title}, body)`);
+  await gitApi().updatePullRequest(
+    // @ts-ignore
+    { title, description: vstsHelper.max4000Chars(body) },
+    config.repoId,
+    prNo
+  );
+}
+
+async function isBranchStale(branchName) {
+  logger.info(`isBranchStale(${branchName})`);
+  // Check if branch's parent SHA = master SHA
+  const branchCommit = await getBranchCommit(branchName);
+  logger.debug(`branchCommit=${branchCommit}`);
+  const commitDetails = await vstsHelper.getCommitDetails(
+    branchCommit,
+    config.repoId
+  );
+  logger.debug({ commitDetails }, `commitDetails`);
+  const parentSha = commitDetails.parents[0];
+  logger.debug(`parentSha=${parentSha}`);
+  logger.debug(`config.baseCommitSHA=${config.baseCommitSHA}`);
+  // Return true if the SHAs don't match
+  return parentSha !== config.baseCommitSHA;
+}
+
+async function ensureComment(issueNo, topic, content) {
+  logger.debug(`ensureComment(${issueNo}, ${topic}, content)`);
+  const body = `### ${topic}\n\n${content}`;
+  await gitApi().createThread(
+    {
+      comments: [{ content: body, commentType: 1, parentCommentId: 0 }],
+      status: 1,
+    },
+    config.repoId,
+    issueNo
+  );
+}
+
+async function ensureCommentRemoval(issueNo, topic) {
+  logger.debug(`ensureCommentRemoval(issueNo, topic)(${issueNo}, ${topic})`);
+  if (issueNo) {
+    const threads = await gitApi().getThreads(config.repoId, issueNo);
+    let threadIdFound = null;
+
+    threads.forEach(thread => {
+      if (thread.comments[0].content.startsWith(`### ${topic}\n\n`)) {
+        threadIdFound = thread.id;
+      }
+    });
+
+    if (threadIdFound) {
+      await gitApi().updateThread(
+        {
+          status: 4, // close
+        },
+        config.repoId,
+        issueNo,
+        threadIdFound
+      );
+    }
+  }
+}
+
+async function getAllRenovateBranches(branchPrefix) {
+  logger.debug(`getAllRenovateBranches(branchPrefix)(${branchPrefix})`);
+  const branches = await gitApi().getBranches(config.repoId);
+  return branches.filter(c => c.name.startsWith(branchPrefix)).map(c => c.name);
+}
+
+async function deleteBranch(branchName) {
+  logger.debug(`deleteBranch(branchName)(${branchName})`);
+  const ref = await vstsHelper.getRefs(
+    config.repoId,
+    vstsHelper.getNewBranchName(branchName)
+  );
+  return gitApi().updateRefs(
+    [
+      {
+        name: ref[0].name,
+        oldObjectId: ref[0].objectId,
+        newObjectId: '0000000000000000000000000000000000000000',
+      },
+    ],
+    config.repoId
+  );
+
+  // TODO: Delete PR too? or put it to abandon?
+}
+
+async function getBranchLastCommitTime(branchName) {
+  logger.debug(`getBranchLastCommitTime(branchName)(${branchName})`);
+  const branch = await gitApi().getBranch(
+    config.repoId,
+    vstsHelper.getBranchNameWithoutRefsheadsPrefix(branchName)
+  );
+  return branch.commit.committer.date;
+}
+
+function setBranchStatus(branchName, context, description, state, targetUrl) {
+  logger.debug(
+    `setBranchStatus(${branchName}, ${context}, ${description}, ${state}, ${
+      targetUrl
+    }) - Not supported by VSTS (yet!)`
+  );
+}
+
+async function mergeBranch(branchName, mergeType) {
+  logger.info(
+    `mergeBranch(branchName, mergeType)(${branchName}, ${
+      mergeType
+    }) - Not supported by VSTS (yet!)`
+  );
+  await null;
+}
+
+async function mergePr(pr) {
+  logger.info(`mergePr(pr)(${pr}) - Not supported by VSTS (yet!)`);
+  await null;
+}
+
+async function addAssignees(issueNo, assignees) {
+  logger.info(
+    `addAssignees(issueNo, assignees)(${issueNo}, ${
+      assignees
+    }) - Not supported by VSTS (yet!)`
+  );
+  await null;
+}
+
+async function addReviewers(issueNo, reviewers) {
+  logger.info(
+    `addReviewers(issueNo, reviewers)(${issueNo}, ${
+      reviewers
+    }) - Not supported by VSTS (yet!)`
+  );
+  await null;
+}
+
+// to become async?
+function getPrFiles(prNo) {
+  logger.info(`getPrFiles(prNo)(${prNo}) - Not supported by VSTS (yet!)`);
+  return [];
+}
diff --git a/lib/platform/vsts/vsts-got-wrapper.js b/lib/platform/vsts/vsts-got-wrapper.js
new file mode 100644
index 0000000000..6b47efe59f
--- /dev/null
+++ b/lib/platform/vsts/vsts-got-wrapper.js
@@ -0,0 +1,19 @@
+const vsts = require('vso-node-api');
+
+function gitApi() {
+  if (!process.env.VSTS_TOKEN) {
+    throw new Error(`No token found for vsts`);
+  }
+  if (!process.env.VSTS_ENDPOINT) {
+    throw new Error(
+      `You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)`
+    );
+  }
+  const authHandler = vsts.getPersonalAccessTokenHandler(
+    process.env.VSTS_TOKEN
+  );
+  const connect = new vsts.WebApi(process.env.VSTS_ENDPOINT, authHandler);
+  return connect.getGitApi();
+}
+
+module.exports = gitApi;
diff --git a/lib/platform/vsts/vsts-helper.js b/lib/platform/vsts/vsts-helper.js
new file mode 100644
index 0000000000..65c5e18561
--- /dev/null
+++ b/lib/platform/vsts/vsts-helper.js
@@ -0,0 +1,256 @@
+// @ts-nocheck
+
+const gitApi = require('./vsts-got-wrapper');
+
+module.exports = {
+  setTokenAndEndpoint,
+  getBranchNameWithoutRefsheadsPrefix,
+  getRefs,
+  getVSTSBranchObj,
+  getVSTSCommitObj,
+  getNewBranchName,
+  getFile,
+  max4000Chars,
+  getRenovatePRFormat,
+  getCommitDetails,
+};
+
+/**
+ *
+ * @param {string} token
+ * @param {string} endpoint
+ */
+function setTokenAndEndpoint(token, endpoint) {
+  if (token) {
+    process.env.VSTS_TOKEN = token;
+  } else if (!process.env.VSTS_TOKEN) {
+    throw new Error(`No token found for vsts`);
+  }
+  if (endpoint) {
+    process.env.VSTS_ENDPOINT = endpoint;
+  } else {
+    throw new Error(
+      `You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)`
+    );
+  }
+}
+
+/**
+ *
+ * @param {string} branchName
+ */
+function getNewBranchName(branchName) {
+  if (branchName && !branchName.startsWith('refs/heads/')) {
+    return `refs/heads/${branchName}`;
+  }
+  return branchName;
+}
+
+/**
+ *
+ * @param {string} branchPath
+ */
+function getBranchNameWithoutRefsheadsPrefix(branchPath) {
+  if (!branchPath) {
+    logger.error(`getBranchNameWithoutRefsheadsPrefix(${branchPath})`);
+    return null;
+  }
+  if (!branchPath.startsWith('refs/heads/')) {
+    logger.trace(
+      `The refs/heads/ name should have started with 'refs/heads/' but it didn't. (${
+        branchPath
+      })`
+    );
+    return branchPath;
+  }
+  return branchPath.substring(11, branchPath.length);
+}
+
+/**
+ *
+ * @param {string} branchPath
+ */
+function getBranchNameWithoutRefsPrefix(branchPath) {
+  if (!branchPath) {
+    logger.error(`getBranchNameWithoutRefsPrefix(${branchPath})`);
+    return null;
+  }
+  if (!branchPath.startsWith('refs/')) {
+    logger.trace(
+      `The ref name should have started with 'refs/' but it didn't. (${
+        branchPath
+      })`
+    );
+    return branchPath;
+  }
+  return branchPath.substring(5, branchPath.length);
+}
+
+/**
+ *
+ * @param {string} repoId
+ * @param {string} branchName
+ */
+async function getRefs(repoId, branchName) {
+  logger.debug(`getRefs(${repoId}, ${branchName})`);
+  const refs = await gitApi().getRefs(
+    repoId,
+    null,
+    getBranchNameWithoutRefsPrefix(branchName)
+  );
+  return refs;
+}
+
+/**
+ *
+ * @param {string} branchName
+ * @param {string} from
+ */
+async function getVSTSBranchObj(repoId, branchName, from) {
+  const fromBranchName = getNewBranchName(from);
+  const refs = await getRefs(repoId, fromBranchName);
+  if (refs.length === 0) {
+    logger.debug(`getVSTSBranchObj without a valid from, so initial commit.`);
+    return {
+      name: getNewBranchName(branchName),
+      oldObjectId: '0000000000000000000000000000000000000000',
+    };
+  }
+  return {
+    name: getNewBranchName(branchName),
+    oldObjectId: refs[0].objectId,
+  };
+}
+/**
+ *
+ * @param {string} msg
+ * @param {string} filePath
+ * @param {string} fileContent
+ * @param {string} repoId
+ * @param {string} repoName
+ * @param {string} branchName
+ */
+async function getVSTSCommitObj(
+  msg,
+  filePath,
+  fileContent,
+  repoId,
+  repoName,
+  branchName
+) {
+  // Add or update
+  let changeType = 1;
+  const fileAlreadyThere = await getFile(
+    repoId,
+    repoName,
+    filePath,
+    branchName
+  );
+  if (fileAlreadyThere) {
+    changeType = 2;
+  }
+
+  return {
+    comment: msg,
+    author: {
+      name: 'VSTS Renovate', // Todo... this is not working
+    },
+    committer: {
+      name: 'VSTS Renovate', // Todo... this is not working
+    },
+    changes: [
+      {
+        changeType,
+        item: {
+          path: filePath,
+        },
+        newContent: {
+          Content: fileContent,
+          ContentType: 0, // RawText
+        },
+      },
+    ],
+  };
+}
+
+/**
+ * if no branchName, look globaly
+ * @param {string} repoId
+ * @param {string} repoName
+ * @param {string} filePath
+ * @param {string} branchName
+ */
+async function getFile(repoId, repoName, filePath, branchName) {
+  logger.trace(`getFile(filePath=${filePath}, branchName=${branchName})`);
+  const item = await gitApi().getItemText(
+    repoId,
+    filePath,
+    null,
+    null,
+    0, // because we look for 1 file
+    false,
+    false,
+    true,
+    {
+      versionType: 0, // branch
+      versionOptions: 0,
+      version: getBranchNameWithoutRefsheadsPrefix(branchName),
+    }
+  );
+
+  if (item && item.readable) {
+    const buffer = item.read();
+    // @ts-ignore
+    const fileContent = Buffer.from(buffer, 'base64').toString();
+    try {
+      const jTmp = JSON.parse(fileContent);
+      if (jTmp.typeKey === 'GitItemNotFoundException') {
+        // file not found
+        return null;
+      } else if (jTmp.typeKey === 'GitUnresolvableToCommitException') {
+        // branch not found
+        return null;
+      }
+    } catch (error) {
+      // it 's not a JSON, so I send the content directly with the line under
+    }
+    return fileContent;
+  }
+  return null; // no file found
+}
+
+/**
+ *
+ * @param {string} str
+ */
+function max4000Chars(str) {
+  if (str.length >= 4000) {
+    return str.substring(0, 3999);
+  }
+  return str;
+}
+
+function getRenovatePRFormat(vstsPr) {
+  const pr = vstsPr;
+
+  pr.displayNumber = `Pull Request #${vstsPr.pullRequestId}`;
+  pr.number = vstsPr.pullRequestId;
+
+  if (vstsPr.status === 2 || vstsPr.status === 3) {
+    pr.isClosed = true;
+  } else {
+    pr.isClosed = false;
+  }
+
+  if (vstsPr.mergeStatus === 2) {
+    pr.isUnmergeable = true;
+  }
+
+  return pr;
+}
+
+async function getCommitDetails(commit, repoId) {
+  logger.debug(`getCommitDetails(${commit}, ${repoId})`);
+  const results = await gitApi().getCommit(commit, repoId);
+  return results;
+}
diff --git a/lib/workers/global/index.js b/lib/workers/global/index.js
index c9411d2cd6..458cec4c39 100644
--- a/lib/workers/global/index.js
+++ b/lib/workers/global/index.js
@@ -46,5 +46,6 @@ function getRepositoryConfig(globalConfig, index) {
   const repoConfig = configParser.mergeChildConfig(globalConfig, repository);
   repoConfig.isGitHub = repoConfig.platform === 'github';
   repoConfig.isGitLab = repoConfig.platform === 'gitlab';
+  repoConfig.isVsts = repoConfig.platform === 'vsts';
   return configParser.filterConfig(repoConfig, 'repository');
 }
diff --git a/lib/workers/pr/index.js b/lib/workers/pr/index.js
index 3889723de7..a29adc7a06 100644
--- a/lib/workers/pr/index.js
+++ b/lib/workers/pr/index.js
@@ -144,6 +144,13 @@ async function ensurePr(prConfig) {
       .replace('</h4>', ' </h4>') // See #954
       .replace(/Pull Request/g, 'Merge Request')
       .replace(/PR/g, 'MR');
+  } else if (config.isVsts) {
+    // Remove any HTML we use
+    prBody = prBody
+      .replace('<summary>', '**')
+      .replace('</summary>', '**')
+      .replace('<details>', '')
+      .replace('</details>', '');
   }
 
   try {
diff --git a/package.json b/package.json
index 00dd72b413..403521b3f6 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,9 @@
     "update"
   ],
   "author": "Rhys Arkins <rhys@arkins.net>",
+  "contributors": [
+    "Jean-Yves Couët <jycouet@gmail.com>"
+  ],
   "license": "MIT",
   "bugs": {
     "url": "https://github.com/singapore/renovate/issues"
@@ -72,7 +75,8 @@
     "showdown": "1.8.2",
     "tmp-promise": "1.0.4",
     "traverse": "0.6.6",
-    "yarn": "1.3.2"
+    "yarn": "1.3.2",
+    "vso-node-api": "6.2.8-preview"
   },
   "devDependencies": {
     "babel-plugin-transform-object-rest-spread": "6.26.0",
diff --git a/readme.md b/readme.md
index 70b03498e6..32a6950f0d 100644
--- a/readme.md
+++ b/readme.md
@@ -18,7 +18,7 @@ Keep dependencies up-to-date.
 -   Configurable via file, environment, CLI, and `package.json`
 -   Supports eslint-like preset configs for ease of use
 -   Updates `yarn.lock` and `package-lock.json` files natively
--   Supports GitHub and GitLab
+-   Supports GitHub, GitLab and VSTS (in beta)
 -   Open source and can be self-hosted or used via GitHub App
 
 ## Configuration Help
@@ -43,8 +43,10 @@ You can find instructions for GitHub [here](https://help.github.com/articles/cre
 
 You can find instructions for GitLab [here](https://docs.gitlab.com/ee/api/README.html#personal-access-tokens).
 
+You can find instructions for VSTS [vsts](https://www.visualstudio.com/en-us/docs/integrate/get-started/authentication/pats).
+
 This token needs to be configured via file, environment variable, or CLI. See [docs/configuration.md](docs/configuration.md) for details.
-The simplest way is to expose it as `GITHUB_TOKEN` or `GITLAB_TOKEN`.
+The simplest way is to expose it as `GITHUB_TOKEN` or `GITLAB_TOKEN` or `VSTS_TOKEN`.
 
 ## Usage
 
diff --git a/test/config/index.spec.js b/test/config/index.spec.js
index 4abc970c28..4e306243c0 100644
--- a/test/config/index.spec.js
+++ b/test/config/index.spec.js
@@ -7,6 +7,8 @@ describe('config/index', () => {
     let defaultArgv;
     let ghGot;
     let get;
+    let gitApi;
+    let vstsHelper;
     beforeEach(() => {
       jest.resetModules();
       configParser = require('../../lib/config/index.js');
@@ -15,6 +17,10 @@ describe('config/index', () => {
       ghGot = require('gh-got');
       jest.mock('gl-got');
       get = require('gl-got');
+      jest.mock('../../lib/platform/vsts/vsts-got-wrapper');
+      gitApi = require('../../lib/platform/vsts/vsts-got-wrapper');
+      jest.mock('../../lib/platform/vsts/vsts-helper');
+      vstsHelper = require('../../lib/platform/vsts/vsts-helper');
     });
     it('throws for invalid platform', async () => {
       const env = {};
@@ -47,6 +53,16 @@ describe('config/index', () => {
       }
       expect(err.message).toBe('You need to supply a GitLab token.');
     });
+    it('throws for no vsts token', async () => {
+      const env = { RENOVATE_PLATFORM: 'vsts' };
+      let err;
+      try {
+        await configParser.parseConfigs(env, defaultArgv);
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe('You need to supply a VSTS token.');
+    });
     it('supports token in env', async () => {
       const env = { GITHUB_TOKEN: 'abc' };
       await configParser.parseConfigs(env, defaultArgv);
@@ -92,6 +108,29 @@ describe('config/index', () => {
       expect(ghGot.mock.calls.length).toBe(0);
       expect(get.mock.calls.length).toBe(1);
     });
+    it('autodiscovers vsts platform', async () => {
+      const env = {};
+      defaultArgv = defaultArgv.concat([
+        '--autodiscover',
+        '--platform=vsts',
+        '--token=abc',
+      ]);
+      vstsHelper.getFile.mockImplementationOnce(() => `Hello Renovate!`);
+      gitApi.mockImplementationOnce(() => ({
+        getRepositories: jest.fn(() => [
+          {
+            name: 'a/b',
+          },
+          {
+            name: 'c/d',
+          },
+        ]),
+      }));
+      await configParser.parseConfigs(env, defaultArgv);
+      expect(ghGot.mock.calls.length).toBe(0);
+      expect(get.mock.calls.length).toBe(0);
+      expect(gitApi.mock.calls.length).toBe(1);
+    });
     it('logs if no autodiscovered repositories', async () => {
       const env = { GITHUB_TOKEN: 'abc' };
       defaultArgv = defaultArgv.concat(['--autodiscover']);
diff --git a/test/platform/__snapshots__/index.spec.js.snap b/test/platform/__snapshots__/index.spec.js.snap
index 985ec7ba90..82f61393e7 100644
--- a/test/platform/__snapshots__/index.spec.js.snap
+++ b/test/platform/__snapshots__/index.spec.js.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`platform has same API for github and gitlab 1`] = `
+exports[`platform has a list of supported methods for github 1`] = `
 Array [
   "getRepos",
   "initRepo",
@@ -32,7 +32,39 @@ Array [
 ]
 `;
 
-exports[`platform has same API for github and gitlab 2`] = `
+exports[`platform has a list of supported methods for gitlab 1`] = `
+Array [
+  "getRepos",
+  "initRepo",
+  "setBaseBranch",
+  "getFileList",
+  "branchExists",
+  "getAllRenovateBranches",
+  "isBranchStale",
+  "getBranchPr",
+  "getBranchStatus",
+  "getBranchStatusCheck",
+  "setBranchStatus",
+  "deleteBranch",
+  "mergeBranch",
+  "getBranchLastCommitTime",
+  "addAssignees",
+  "addReviewers",
+  "ensureComment",
+  "ensureCommentRemoval",
+  "findPr",
+  "createPr",
+  "getPr",
+  "getPrFiles",
+  "updatePr",
+  "mergePr",
+  "commitFilesToBranch",
+  "getFile",
+  "getCommitMessages",
+]
+`;
+
+exports[`platform has a list of supported methods for vsts 1`] = `
 Array [
   "getRepos",
   "initRepo",
diff --git a/test/platform/index.spec.js b/test/platform/index.spec.js
index 46658e3996..dac18f8aaf 100644
--- a/test/platform/index.spec.js
+++ b/test/platform/index.spec.js
@@ -1,12 +1,32 @@
 const github = require('../../lib/platform/github');
 const gitlab = require('../../lib/platform/gitlab');
+const vsts = require('../../lib/platform/vsts');
 
 describe('platform', () => {
-  it('has same API for github and gitlab', () => {
+  it('has a list of supported methods for github', () => {
     const githubMethods = Object.keys(github);
-    const gitlabMethods = Object.keys(gitlab);
     expect(githubMethods).toMatchSnapshot();
+  });
+
+  it('has a list of supported methods for gitlab', () => {
+    const gitlabMethods = Object.keys(gitlab);
     expect(gitlabMethods).toMatchSnapshot();
+  });
+
+  it('has a list of supported methods for vsts', () => {
+    const vstsMethods = Object.keys(vsts);
+    expect(vstsMethods).toMatchSnapshot();
+  });
+
+  it('has same API for github and gitlab', () => {
+    const githubMethods = Object.keys(github);
+    const gitlabMethods = Object.keys(gitlab);
     expect(githubMethods).toMatchObject(gitlabMethods);
   });
+
+  it('has same API for github and vsts', () => {
+    const githubMethods = Object.keys(github);
+    const vstsMethods = Object.keys(vsts);
+    expect(githubMethods).toMatchObject(vstsMethods);
+  });
 });
diff --git a/test/platform/vsts/__snapshots__/index.spec.js.snap b/test/platform/vsts/__snapshots__/index.spec.js.snap
new file mode 100644
index 0000000000..1b1c18bd19
--- /dev/null
+++ b/test/platform/vsts/__snapshots__/index.spec.js.snap
@@ -0,0 +1,185 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`platform/vsts createPr() should create and return a PR object 1`] = `
+Object {
+  "displayNumber": "Pull Request #456",
+  "number": 456,
+  "pullRequestId": 456,
+}
+`;
+
+exports[`platform/vsts createPr() should create and return a PR object from base branch 1`] = `
+Object {
+  "displayNumber": "Pull Request #456",
+  "number": 456,
+  "pullRequestId": 456,
+}
+`;
+
+exports[`platform/vsts deleteBranch should delete the branch 1`] = `
+Array [
+  Object {
+    "name": "refs/head/testBranch",
+    "newObjectId": "0000000000000000000000000000000000000000",
+    "oldObjectId": "123456",
+  },
+]
+`;
+
+exports[`platform/vsts ensureComment add comment 1`] = `
+Array [
+  Array [],
+  Array [],
+  Array [],
+]
+`;
+
+exports[`platform/vsts findPr(branchName, prTitle, state) returns pr if found it all state 1`] = `
+Object {
+  "head": Object {
+    "ref": "branch-a",
+  },
+  "isClosed": true,
+  "number": 1,
+  "title": "branch a pr",
+}
+`;
+
+exports[`platform/vsts findPr(branchName, prTitle, state) returns pr if found it but add an error 1`] = `
+Object {
+  "head": Object {
+    "ref": "branch-a",
+  },
+  "isClosed": true,
+  "number": 1,
+  "title": "branch a pr",
+}
+`;
+
+exports[`platform/vsts findPr(branchName, prTitle, state) returns pr if found it close 1`] = `
+Object {
+  "head": Object {
+    "ref": "branch-a",
+  },
+  "isClosed": true,
+  "number": 1,
+  "title": "branch a pr",
+}
+`;
+
+exports[`platform/vsts findPr(branchName, prTitle, state) returns pr if found it open 1`] = `
+Object {
+  "head": Object {
+    "ref": "branch-a",
+  },
+  "isClosed": false,
+  "number": 1,
+  "title": "branch a pr",
+}
+`;
+
+exports[`platform/vsts getAllRenovateBranches() should return all renovate branches 1`] = `
+Array [
+  "renovate/a",
+  "renovate/b",
+]
+`;
+
+exports[`platform/vsts getBranchLastCommitTime should return a Date 1`] = `"1986-11-07T00:00:00Z"`;
+
+exports[`platform/vsts getBranchPr(branchName) should return the pr 1`] = `
+Object {
+  "head": Object {
+    "ref": "branch-a",
+  },
+  "isClosed": false,
+  "number": 1,
+  "pullRequestId": 1,
+  "title": "branch a pr",
+}
+`;
+
+exports[`platform/vsts getCommitMessages() returns commits messages 1`] = `
+Array [
+  "com1",
+  "com2",
+  "com3",
+]
+`;
+
+exports[`platform/vsts getFile(filePatch, branchName) should return the encoded file content 1`] = `"Hello Renovate!"`;
+
+exports[`platform/vsts getFileList should return the files matching the fileName 1`] = `
+Array [
+  "package.json",
+  "src/app/package.json",
+  "src/otherapp/package.json",
+  "symlinks/package.json",
+]
+`;
+
+exports[`platform/vsts getPr(prNo) should return a pr in the right format 1`] = `
+Object {
+  "pullRequestId": 1234,
+}
+`;
+
+exports[`platform/vsts getRepos should return an array of repos 1`] = `
+Array [
+  Array [],
+]
+`;
+
+exports[`platform/vsts getRepos should return an array of repos 2`] = `
+Array [
+  "a/b",
+  "c/d",
+]
+`;
+
+exports[`platform/vsts initRepo should initialise the config for a repo 1`] = `
+Array [
+  Array [],
+  Array [],
+]
+`;
+
+exports[`platform/vsts initRepo should initialise the config for a repo 2`] = `
+Object {
+  "baseBranch": "defBr",
+  "baseCommitSHA": "1234",
+  "defaultBranch": "defBr",
+  "fileList": null,
+  "isFork": false,
+  "mergeMethod": "merge",
+  "owner": "?owner?",
+  "prList": null,
+  "privateRepo": true,
+  "repoForceRebase": false,
+  "repoId": "1",
+  "repoName": "some/repo",
+}
+`;
+
+exports[`platform/vsts setBaseBranch(branchName) sets the base branch 1`] = `
+Array [
+  Array [],
+  Array [],
+  Array [],
+]
+`;
+
+exports[`platform/vsts setBaseBranch(branchName) sets the base branch 2`] = `
+Array [
+  Array [],
+  Array [],
+]
+`;
+
+exports[`platform/vsts updatePr(prNo, title, body) should update the PR 1`] = `
+Array [
+  Array [],
+  Array [],
+  Array [],
+]
+`;
diff --git a/test/platform/vsts/__snapshots__/vsts-got-wrapper.spec.js.snap b/test/platform/vsts/__snapshots__/vsts-got-wrapper.spec.js.snap
new file mode 100644
index 0000000000..ce50951676
--- /dev/null
+++ b/test/platform/vsts/__snapshots__/vsts-got-wrapper.spec.js.snap
@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`platform/vsts/vsts-got-wrapper gitApi should set token and endpoint 1`] = `
+GitApi {
+  "baseUrl": "myEndpoint",
+  "http": HttpClient {
+    "_certConfig": undefined,
+    "_httpProxy": undefined,
+    "_ignoreSslError": false,
+    "_socketTimeout": undefined,
+    "handlers": Array [
+      PersonalAccessTokenCredentialHandler {
+        "token": "myToken",
+      },
+    ],
+    "requestOptions": Object {},
+    "userAgent": "node-Git-api",
+  },
+  "rest": RestClient {
+    "client": HttpClient {
+      "_certConfig": undefined,
+      "_httpProxy": undefined,
+      "_ignoreSslError": false,
+      "_socketTimeout": undefined,
+      "handlers": Array [
+        PersonalAccessTokenCredentialHandler {
+          "token": "myToken",
+        },
+      ],
+      "requestOptions": Object {},
+      "userAgent": "node-Git-api",
+    },
+  },
+  "userAgent": "node-Git-api",
+  "vsoClient": VsoClient {
+    "_initializationPromise": Promise {},
+    "_locationsByAreaPromises": Object {},
+    "basePath": "myEndpoint",
+    "baseUrl": "myEndpoint",
+    "restClient": RestClient {
+      "client": HttpClient {
+        "_certConfig": undefined,
+        "_httpProxy": undefined,
+        "_ignoreSslError": false,
+        "_socketTimeout": undefined,
+        "handlers": Array [
+          PersonalAccessTokenCredentialHandler {
+            "token": "myToken",
+          },
+        ],
+        "requestOptions": Object {},
+        "userAgent": "node-Git-api",
+      },
+    },
+  },
+}
+`;
diff --git a/test/platform/vsts/__snapshots__/vsts-helper.spec.js.snap b/test/platform/vsts/__snapshots__/vsts-helper.spec.js.snap
new file mode 100644
index 0000000000..c977866b9c
--- /dev/null
+++ b/test/platform/vsts/__snapshots__/vsts-helper.spec.js.snap
@@ -0,0 +1,128 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`platform/vsts/helpers getCommitDetails should get commit details 1`] = `
+Object {
+  "parents": Array [
+    "123456",
+  ],
+}
+`;
+
+exports[`platform/vsts/helpers getFile should return the file content because it is not a json 1`] = `"{\\"hello\\"= \\"test\\"}"`;
+
+exports[`platform/vsts/helpers getRef should get the ref 1`] = `
+Array [
+  Object {
+    "objectId": 132,
+  },
+]
+`;
+
+exports[`platform/vsts/helpers getRef should get the ref 2`] = `
+Array [
+  Object {
+    "objectId": "132",
+  },
+]
+`;
+
+exports[`platform/vsts/helpers getRenovatePRFormat should be formated (closed v2) 1`] = `
+Object {
+  "displayNumber": "Pull Request #undefined",
+  "isClosed": true,
+  "number": undefined,
+  "status": 3,
+}
+`;
+
+exports[`platform/vsts/helpers getRenovatePRFormat should be formated (closed) 1`] = `
+Object {
+  "displayNumber": "Pull Request #undefined",
+  "isClosed": true,
+  "number": undefined,
+  "status": 2,
+}
+`;
+
+exports[`platform/vsts/helpers getRenovatePRFormat should be formated (isUnmergeable) 1`] = `
+Object {
+  "displayNumber": "Pull Request #undefined",
+  "isClosed": false,
+  "isUnmergeable": true,
+  "mergeStatus": 2,
+  "number": undefined,
+}
+`;
+
+exports[`platform/vsts/helpers getRenovatePRFormat should be formated (not closed) 1`] = `
+Object {
+  "displayNumber": "Pull Request #undefined",
+  "isClosed": false,
+  "number": undefined,
+  "status": 1,
+}
+`;
+
+exports[`platform/vsts/helpers getVSTSBranchObj should be the branch object formated 1`] = `
+Object {
+  "name": "refs/heads/branchName",
+  "oldObjectId": "132",
+}
+`;
+
+exports[`platform/vsts/helpers getVSTSBranchObj should be the branch object formated 2`] = `
+Object {
+  "name": "refs/heads/branchName",
+  "oldObjectId": "0000000000000000000000000000000000000000",
+}
+`;
+
+exports[`platform/vsts/helpers getVSTSCommitObj should be get the commit obj formated (file to create) 1`] = `
+Object {
+  "author": Object {
+    "name": "VSTS Renovate",
+  },
+  "changes": Array [
+    Object {
+      "changeType": 1,
+      "item": Object {
+        "path": "./myFilePath/test",
+      },
+      "newContent": Object {
+        "Content": "Hello world!",
+        "ContentType": 0,
+      },
+    },
+  ],
+  "comment": "Commit msg",
+  "committer": Object {
+    "name": "VSTS Renovate",
+  },
+}
+`;
+
+exports[`platform/vsts/helpers getVSTSCommitObj should be get the commit obj formated (file to update) 1`] = `
+Object {
+  "author": Object {
+    "name": "VSTS Renovate",
+  },
+  "changes": Array [
+    Object {
+      "changeType": 2,
+      "item": Object {
+        "path": "./myFilePath/test",
+      },
+      "newContent": Object {
+        "Content": "Hello world!",
+        "ContentType": 0,
+      },
+    },
+  ],
+  "comment": "Commit msg",
+  "committer": Object {
+    "name": "VSTS Renovate",
+  },
+}
+`;
+
+exports[`platform/vsts/helpers max4000Chars should be the same 1`] = `"Hello"`;
diff --git a/test/platform/vsts/index.spec.js b/test/platform/vsts/index.spec.js
new file mode 100644
index 0000000000..584c73e7a1
--- /dev/null
+++ b/test/platform/vsts/index.spec.js
@@ -0,0 +1,631 @@
+describe('platform/vsts', () => {
+  let vsts;
+  let gitApi;
+  let vstsHelper;
+  beforeEach(() => {
+    // clean up env
+    delete process.env.VSTS_TOKEN;
+    delete process.env.VSTS_ENDPOINT;
+
+    // reset module
+    jest.resetModules();
+    jest.mock('../../../lib/platform/vsts/vsts-got-wrapper');
+    jest.mock('../../../lib/platform/vsts/vsts-helper');
+    vsts = require('../../../lib/platform/vsts');
+    gitApi = require('../../../lib/platform/vsts/vsts-got-wrapper');
+    vstsHelper = require('../../../lib/platform/vsts/vsts-helper');
+  });
+
+  function getRepos(token, endpoint) {
+    gitApi.mockImplementationOnce(() => ({
+      getRepositories: jest.fn(() => [
+        {
+          name: 'a/b',
+        },
+        {
+          name: 'c/d',
+        },
+      ]),
+    }));
+    return vsts.getRepos(token, endpoint);
+  }
+
+  describe('getRepos', () => {
+    it('should return an array of repos', async () => {
+      const repos = await getRepos(
+        'sometoken',
+        'https://fabrikam.VisualStudio.com/DefaultCollection'
+      );
+      expect(gitApi.mock.calls).toMatchSnapshot();
+      expect(repos).toMatchSnapshot();
+    });
+  });
+
+  function initRepo(...args) {
+    gitApi.mockImplementationOnce(() => ({
+      getRepositories: jest.fn(() => [
+        {
+          name: 'some/repo',
+          id: '1',
+          privateRepo: true,
+          isFork: false,
+          defaultBranch: 'defBr',
+        },
+        {
+          name: 'c/d',
+        },
+      ]),
+    }));
+    gitApi.mockImplementationOnce(() => ({
+      getBranch: jest.fn(() => ({ commit: { commitId: '1234' } })),
+    }));
+
+    return vsts.initRepo(...args);
+  }
+
+  describe('initRepo', () => {
+    it(`should initialise the config for a repo`, async () => {
+      const config = await initRepo(
+        'some/repo',
+        'token',
+        'https://my.custom.endpoint/'
+      );
+      expect(gitApi.mock.calls).toMatchSnapshot();
+      expect(config).toMatchSnapshot();
+    });
+  });
+
+  describe('setBaseBranch(branchName)', () => {
+    it('sets the base branch', async () => {
+      await initRepo('some/repo', 'token');
+      // getBranchCommit
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({
+          commit: { commitId: '1234' },
+        })),
+      }));
+      await vsts.setBaseBranch('some-branch');
+      expect(gitApi.mock.calls).toMatchSnapshot();
+    });
+    it('sets the base branch', async () => {
+      await initRepo('some/repo', 'token');
+      // getBranchCommit
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({
+          commit: { commitId: '1234' },
+        })),
+      }));
+      await vsts.setBaseBranch();
+      expect(gitApi.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('getCommitMessages()', () => {
+    it('returns commits messages', async () => {
+      const config = await initRepo(
+        'some/repo',
+        'token',
+        'https://my.custom.endpoint/'
+      );
+      expect(config.repoId).toBe('1');
+      gitApi.mockImplementationOnce(() => ({
+        getCommits: jest.fn(() => [
+          { comment: 'com1' },
+          { comment: 'com2' },
+          { comment: 'com3' },
+        ]),
+      }));
+      const msg = await vsts.getCommitMessages();
+      expect(msg).toMatchSnapshot();
+    });
+    it('returns empty array if error', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => {
+        throw new Error('some error');
+      });
+      const msgs = await vsts.getCommitMessages();
+      expect(msgs).toEqual([]);
+    });
+  });
+
+  describe('getFile(filePatch, branchName)', () => {
+    it('should return the encoded file content', async () => {
+      await initRepo('some/repo', 'token');
+      vstsHelper.getFile.mockImplementationOnce(() => `Hello Renovate!`);
+      const content = await vsts.getFile('package.json');
+      expect(content).toMatchSnapshot();
+    });
+  });
+
+  describe('findPr(branchName, prTitle, state)', () => {
+    it('returns pr if found it open', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => [
+          {
+            pullRequestId: 1,
+            sourceRefName: 'refs/heads/branch-a',
+            title: 'branch a pr',
+            status: 2,
+          },
+        ]),
+      }));
+      vstsHelper.getNewBranchName.mockImplementationOnce(
+        () => 'refs/heads/branch-a'
+      );
+      vstsHelper.getRenovatePRFormat.mockImplementationOnce(() => ({
+        number: 1,
+        head: { ref: 'branch-a' },
+        title: 'branch a pr',
+        isClosed: false,
+      }));
+      const res = await vsts.findPr('branch-a', 'branch a pr', 'open');
+      expect(res).toMatchSnapshot();
+    });
+    it('returns pr if found it close', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => [
+          {
+            pullRequestId: 1,
+            sourceRefName: 'refs/heads/branch-a',
+            title: 'branch a pr',
+            status: 2,
+          },
+        ]),
+      }));
+      vstsHelper.getNewBranchName.mockImplementationOnce(
+        () => 'refs/heads/branch-a'
+      );
+      vstsHelper.getRenovatePRFormat.mockImplementationOnce(() => ({
+        number: 1,
+        head: { ref: 'branch-a' },
+        title: 'branch a pr',
+        isClosed: true,
+      }));
+      const res = await vsts.findPr('branch-a', 'branch a pr', 'closed');
+      expect(res).toMatchSnapshot();
+    });
+    it('returns pr if found it all state', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => [
+          {
+            pullRequestId: 1,
+            sourceRefName: 'refs/heads/branch-a',
+            title: 'branch a pr',
+            status: 2,
+          },
+        ]),
+      }));
+      vstsHelper.getNewBranchName.mockImplementationOnce(
+        () => 'refs/heads/branch-a'
+      );
+      vstsHelper.getRenovatePRFormat.mockImplementationOnce(() => ({
+        number: 1,
+        head: { ref: 'branch-a' },
+        title: 'branch a pr',
+        isClosed: true,
+      }));
+      const res = await vsts.findPr('branch-a', 'branch a pr');
+      expect(res).toMatchSnapshot();
+    });
+    it('returns pr if found it but add an error', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => [
+          {
+            pullRequestId: 1,
+            sourceRefName: 'refs/heads/branch-a',
+            title: 'branch a pr',
+            status: 2,
+          },
+        ]),
+      }));
+      vstsHelper.getNewBranchName.mockImplementationOnce(
+        () => 'refs/heads/branch-a'
+      );
+      vstsHelper.getRenovatePRFormat.mockImplementationOnce(() => ({
+        number: 1,
+        head: { ref: 'branch-a' },
+        title: 'branch a pr',
+        isClosed: true,
+      }));
+      const res = await vsts.findPr('branch-a', 'branch a pr', 'blabla');
+      expect(res).toMatchSnapshot();
+    });
+    it('returns null if error', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => {
+        throw new Error('some error');
+      });
+      const pr = await vsts.findPr('branch-a', 'branch a pr');
+      expect(pr).toBeNull();
+    });
+  });
+
+  describe('getFileList', () => {
+    it('returns empty array if error', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => {
+        throw new Error('some error');
+      });
+      const files = await vsts.getFileList();
+      expect(files).toEqual([]);
+    });
+    it('caches the result', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getItems: jest.fn(() => [
+          { path: '/symlinks/package.json' },
+          { isFolder: false, path: '/package.json' },
+          { isFolder: true, path: '/some-dir' },
+          { type: 'blob', path: '/src/app/package.json' },
+          { type: 'blob', path: '/src/otherapp/package.json' },
+        ]),
+      }));
+      let files = await vsts.getFileList();
+      expect(files.length).toBe(4);
+      files = await vsts.getFileList();
+      expect(files.length).toBe(4);
+    });
+    it('should return the files matching the fileName', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getItems: jest.fn(() => [
+          { path: '/symlinks/package.json' },
+          { isFolder: false, path: '/package.json' },
+          { isFolder: true, path: '/some-dir' },
+          { type: 'blob', path: '/src/app/package.json' },
+          { type: 'blob', path: '/src/otherapp/package.json' },
+        ]),
+      }));
+      const files = await vsts.getFileList();
+      expect(files).toMatchSnapshot();
+    });
+  });
+
+  describe('commitFilesToBranch(branchName, files, message, parentBranch)', () => {
+    it('should add a new commit to the branch', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        createPush: jest.fn(() => true),
+      }));
+      vstsHelper.getVSTSBranchObj.mockImplementationOnce(() => 'newBranch');
+      vstsHelper.getRefs.mockImplementation(() => [{ objectId: '123' }]);
+
+      const files = [
+        {
+          name: 'package.json',
+          contents: 'hello world',
+        },
+      ];
+      await vsts.commitFilesToBranch(
+        'package.json',
+        files,
+        'my commit message'
+      );
+      expect(gitApi.mock.calls.length).toBe(3);
+    });
+    it('should add a new commit to an existing branch', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        createPush: jest.fn(() => true),
+      }));
+      vstsHelper.getVSTSBranchObj.mockImplementationOnce(() => 'newBranch');
+      vstsHelper.getRefs.mockImplementation(() => []);
+
+      const files = [
+        {
+          name: 'package.json',
+          contents: 'hello world',
+        },
+      ];
+      await vsts.commitFilesToBranch(
+        'package.json',
+        files,
+        'my commit message'
+      );
+      expect(gitApi.mock.calls.length).toBe(3);
+    });
+  });
+
+  describe('branchExists(branchName)', () => {
+    it('should return false if the branch does not exist', async () => {
+      await initRepo('some/repo', 'token');
+      vstsHelper.getRefs.mockImplementation(() => []);
+      const exists = await vsts.branchExists('thebranchname');
+      expect(exists).toBe(false);
+    });
+  });
+
+  describe('getBranchPr(branchName)', () => {
+    it('should return null if no PR exists', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        findPr: jest.fn(() => false),
+        getPr: jest.fn(() => {
+          'myPRName';
+        }),
+      }));
+      const pr = await vsts.getBranchPr('somebranch');
+      expect(pr).toBe(null);
+    });
+    it('should return the pr', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementation(() => ({
+        getPullRequests: jest.fn(() => [
+          {
+            pullRequestId: 1,
+            sourceRefName: 'refs/heads/branch-a',
+            title: 'branch a pr',
+            status: 2,
+          },
+        ]),
+      }));
+      vstsHelper.getNewBranchName.mockImplementation(
+        () => 'refs/heads/branch-a'
+      );
+      vstsHelper.getRenovatePRFormat.mockImplementation(() => ({
+        pullRequestId: 1,
+        number: 1,
+        head: { ref: 'branch-a' },
+        title: 'branch a pr',
+        isClosed: false,
+      }));
+      const pr = await vsts.getBranchPr('somebranch');
+      expect(pr).toMatchSnapshot();
+    });
+  });
+
+  describe('getBranchStatus(branchName, requiredStatusChecks)', () => {
+    it('return success if requiredStatusChecks null', async () => {
+      await initRepo('some/repo', 'token');
+      const res = await vsts.getBranchStatus('somebranch', null);
+      expect(res).toEqual('success');
+    });
+    it('return failed if unsupported requiredStatusChecks', async () => {
+      await initRepo('some/repo', 'token');
+      const res = await vsts.getBranchStatus('somebranch', ['foo']);
+      expect(res).toEqual('failed');
+    });
+    it('should pass through success', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({ aheadCount: 0 })),
+      }));
+      const res = await vsts.getBranchStatus('somebranch', []);
+      expect(res).toEqual('success');
+    });
+    it('should pass through failed', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({ aheadCount: 123 })),
+      }));
+      const res = await vsts.getBranchStatus('somebranch', []);
+      expect(res).toEqual('pending');
+    });
+  });
+
+  describe('getPr(prNo)', () => {
+    it('should return null if no prNo is passed', async () => {
+      const pr = await vsts.getPr(null);
+      expect(pr).toBe(null);
+    });
+    it('should return null if no PR is returned from vsts', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => []),
+      }));
+      const pr = await vsts.getPr(1234);
+      expect(pr).toBe(null);
+    });
+    it('should return a pr in the right format', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getPullRequests: jest.fn(() => [{ pullRequestId: 1234 }]),
+      }));
+      vstsHelper.getRenovatePRFormat.mockImplementation(() => ({
+        pullRequestId: 1234,
+      }));
+      const pr = await vsts.getPr(1234);
+      expect(pr).toMatchSnapshot();
+    });
+  });
+
+  describe('createPr()', () => {
+    it('should create and return a PR object', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        createPullRequest: jest.fn(() => ({
+          pullRequestId: 456,
+          displayNumber: `Pull Request #456`,
+        })),
+      }));
+      vstsHelper.getRenovatePRFormat.mockImplementation(() => ({
+        displayNumber: 'Pull Request #456',
+        number: 456,
+        pullRequestId: 456,
+      }));
+      const pr = await vsts.createPr(
+        'some-branch',
+        'The Title',
+        'Hello world',
+        ['deps', 'renovate']
+      );
+      expect(pr).toMatchSnapshot();
+    });
+    it('should create and return a PR object from base branch', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        createPullRequest: jest.fn(() => ({
+          pullRequestId: 456,
+          displayNumber: `Pull Request #456`,
+        })),
+      }));
+      vstsHelper.getRenovatePRFormat.mockImplementation(() => ({
+        displayNumber: 'Pull Request #456',
+        number: 456,
+        pullRequestId: 456,
+      }));
+      const pr = await vsts.createPr(
+        'some-branch',
+        'The Title',
+        'Hello world',
+        ['deps', 'renovate'],
+        true
+      );
+      expect(pr).toMatchSnapshot();
+    });
+  });
+
+  describe('updatePr(prNo, title, body)', () => {
+    it('should update the PR', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        updatePullRequest: jest.fn(),
+      }));
+      await vsts.updatePr(1234, 'The New Title', 'Hello world again');
+      expect(gitApi.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('ensureComment', () => {
+    it('add comment', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementation(() => ({
+        createThread: jest.fn(() => [{ id: 123 }]),
+      }));
+      await vsts.ensureComment(42, 'some-subject', 'some\ncontent');
+      expect(gitApi.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('isBranchStale', () => {
+    it('should return true', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({ commit: { commitId: '123456' } })),
+      }));
+      vstsHelper.getCommitDetails.mockImplementation(() => ({
+        parents: ['789654'],
+      }));
+      const res = await vsts.isBranchStale();
+      expect(res).toBe(true);
+    });
+    it('should return false', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({ commit: { commitId: '123457' } })),
+      }));
+      vstsHelper.getCommitDetails.mockImplementation(() => ({
+        parents: ['1234'],
+      }));
+      const res = await vsts.isBranchStale('branch');
+      expect(res).toBe(false);
+    });
+  });
+
+  describe('getAllRenovateBranches()', () => {
+    it('should return all renovate branches', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranches: jest.fn(() => [
+          { name: 'master' },
+          { name: 'renovate/a' },
+          { name: 'renovate/b' },
+        ]),
+      }));
+      const res = await vsts.getAllRenovateBranches('renovate/');
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('ensureCommentRemoval', () => {
+    it('deletes comment if found', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementation(() => ({
+        getThreads: jest.fn(() => [
+          { comments: [{ content: '### some-subject\n\nblabla' }], id: 123 },
+        ]),
+        updateThread: jest.fn(),
+      }));
+      await vsts.ensureCommentRemoval(42, 'some-subject');
+      expect(gitApi.mock.calls.length).toBe(4);
+    });
+    it('nothing should happen, no number', async () => {
+      await vsts.ensureCommentRemoval();
+      expect(gitApi.mock.calls.length).toBe(0);
+    });
+    it('comment not found', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementation(() => ({
+        getThreads: jest.fn(() => [
+          { comments: [{ content: 'stupid comment' }], id: 123 },
+        ]),
+        updateThread: jest.fn(),
+      }));
+      await vsts.ensureCommentRemoval(42, 'some-subject');
+      expect(gitApi.mock.calls.length).toBe(3);
+    });
+  });
+
+  describe('getBranchLastCommitTime', () => {
+    it('should return a Date', async () => {
+      await initRepo('some/repo', 'token');
+      gitApi.mockImplementationOnce(() => ({
+        getBranch: jest.fn(() => ({
+          commit: { committer: { date: '1986-11-07T00:00:00Z' } },
+        })),
+      }));
+      const res = await vsts.getBranchLastCommitTime('some-branch');
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('deleteBranch', () => {
+    it('should delete the branch', async () => {
+      vstsHelper.getRefs.mockImplementation(() => [{ objectId: '123' }]);
+      gitApi.mockImplementationOnce(() => ({
+        updateRefs: jest.fn(() => [
+          {
+            name: 'refs/head/testBranch',
+            oldObjectId: '123456',
+            newObjectId: '0000000000000000000000000000000000000000',
+          },
+        ]),
+      }));
+      const res = await vsts.deleteBranch();
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('Not supported by VSTS (yet!)', () => {
+    it('setBranchStatus', () => {
+      const res = vsts.setBranchStatus();
+      expect(res).toBeUndefined();
+    });
+
+    it('mergeBranch', async () => {
+      const res = await vsts.mergeBranch();
+      expect(res).toBeUndefined();
+    });
+
+    it('mergePr', async () => {
+      const res = await vsts.mergePr();
+      expect(res).toBeUndefined();
+    });
+
+    it('addAssignees', async () => {
+      const res = await vsts.addAssignees();
+      expect(res).toBeUndefined();
+    });
+
+    it('addReviewers', async () => {
+      const res = await vsts.addReviewers();
+      expect(res).toBeUndefined();
+    });
+
+    // to become async?
+    it('getPrFiles', () => {
+      const res = vsts.getPrFiles(46);
+      expect(res.length).toBe(0);
+    });
+  });
+});
diff --git a/test/platform/vsts/vsts-got-wrapper.spec.js b/test/platform/vsts/vsts-got-wrapper.spec.js
new file mode 100644
index 0000000000..882739e165
--- /dev/null
+++ b/test/platform/vsts/vsts-got-wrapper.spec.js
@@ -0,0 +1,46 @@
+describe('platform/vsts/vsts-got-wrapper', () => {
+  let gitApi;
+  beforeEach(() => {
+    // clean up env
+    delete process.env.VSTS_TOKEN;
+    delete process.env.VSTS_ENDPOINT;
+
+    // reset module
+    jest.resetModules();
+    gitApi = require('../../../lib/platform/vsts/vsts-got-wrapper');
+  });
+
+  describe('gitApi', () => {
+    it('should throw an error if no token is provided', async () => {
+      let err;
+      try {
+        await gitApi();
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe('No token found for vsts');
+    });
+    it('should throw an error if no endpoint is provided', async () => {
+      let err;
+      try {
+        process.env.VSTS_TOKEN = 'myToken';
+        await gitApi();
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe(
+        `You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)`
+      );
+    });
+    it('should set token and endpoint', async () => {
+      process.env.VSTS_TOKEN = 'myToken';
+      process.env.VSTS_ENDPOINT = 'myEndpoint';
+      const res = await gitApi();
+
+      // We will track if the lib vso-node-api change
+      expect(res).toMatchSnapshot();
+      expect(process.env.VSTS_TOKEN).toBe(`myToken`);
+      expect(process.env.VSTS_ENDPOINT).toBe(`myEndpoint`);
+    });
+  });
+});
diff --git a/test/platform/vsts/vsts-helper.spec.js b/test/platform/vsts/vsts-helper.spec.js
new file mode 100644
index 0000000000..f1b34991cf
--- /dev/null
+++ b/test/platform/vsts/vsts-helper.spec.js
@@ -0,0 +1,295 @@
+describe('platform/vsts/helpers', () => {
+  let vstsHelper;
+  let gitApi;
+
+  beforeEach(() => {
+    // clean up env
+    delete process.env.VSTS_TOKEN;
+    delete process.env.VSTS_ENDPOINT;
+
+    // reset module
+    jest.resetModules();
+    jest.mock('../../../lib/platform/vsts/vsts-got-wrapper');
+    vstsHelper = require('../../../lib/platform/vsts/vsts-helper');
+    gitApi = require('../../../lib/platform/vsts/vsts-got-wrapper');
+  });
+
+  describe('getRepos', () => {
+    it('should throw an error if no token is provided', async () => {
+      let err;
+      try {
+        await vstsHelper.setTokenAndEndpoint();
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe('No token found for vsts');
+    });
+    it('should throw an error if no endpoint provided (with env variable on token)', async () => {
+      let err;
+      process.env.VSTS_TOKEN = 'token123';
+      try {
+        await vstsHelper.setTokenAndEndpoint();
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe(
+        'You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)'
+      );
+    });
+    it('should throw an error if no endpoint is provided', async () => {
+      let err;
+      try {
+        await vstsHelper.setTokenAndEndpoint('myToken');
+      } catch (e) {
+        err = e;
+      }
+      expect(err.message).toBe(
+        `You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)`
+      );
+    });
+    it('should set token and endpoint', async () => {
+      await vstsHelper.setTokenAndEndpoint('myToken', 'myEndpoint');
+      expect(process.env.VSTS_TOKEN).toBe(`myToken`);
+      expect(process.env.VSTS_ENDPOINT).toBe(`myEndpoint`);
+    });
+  });
+
+  describe('getNewBranchName', () => {
+    it('should add refs/heads', () => {
+      const res = vstsHelper.getNewBranchName('testBB');
+      expect(res).toBe(`refs/heads/testBB`);
+    });
+    it('should be the same', () => {
+      const res = vstsHelper.getNewBranchName('refs/heads/testBB');
+      expect(res).toBe(`refs/heads/testBB`);
+    });
+  });
+
+  describe('getBranchNameWithoutRefsheadsPrefix', () => {
+    it('should be renamed', () => {
+      const res = vstsHelper.getBranchNameWithoutRefsheadsPrefix(
+        'refs/heads/testBB'
+      );
+      expect(res).toBe(`testBB`);
+    });
+    it('should log error and return null', () => {
+      const res = vstsHelper.getBranchNameWithoutRefsheadsPrefix();
+      expect(res).toBeNull();
+    });
+    it('should return the input', () => {
+      const res = vstsHelper.getBranchNameWithoutRefsheadsPrefix('testBB');
+      expect(res).toBe('testBB');
+    });
+  });
+
+  describe('getRef', () => {
+    it('should get the ref', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getRefs: jest.fn(() => [{ objectId: 132 }]),
+      }));
+      const res = await vstsHelper.getRefs('123', 'branch');
+      expect(res).toMatchSnapshot();
+    });
+    it('should get 0 ref', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getRefs: jest.fn(() => []),
+      }));
+      const res = await vstsHelper.getRefs('123');
+      expect(res.length).toBe(0);
+    });
+    it('should get the ref', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getRefs: jest.fn(() => [{ objectId: '132' }]),
+      }));
+      const res = await vstsHelper.getRefs('123', 'refs/head/branch1');
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('getVSTSBranchObj', () => {
+    it('should be the branch object formated', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getRefs: jest.fn(() => [{ objectId: '132' }]),
+      }));
+      const res = await vstsHelper.getVSTSBranchObj(
+        '123',
+        'branchName',
+        'base'
+      );
+      expect(res).toMatchSnapshot();
+    });
+    it('should be the branch object formated', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getRefs: jest.fn(() => []),
+      }));
+      const res = await vstsHelper.getVSTSBranchObj('123', 'branchName');
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('getVSTSCommitObj', () => {
+    it('should be get the commit obj formated (file to update)', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => ({
+          readable: true,
+          read: jest.fn(() =>
+            Buffer.from('{"hello": "test"}').toString('base64')
+          ),
+        })),
+      }));
+
+      const res = await vstsHelper.getVSTSCommitObj(
+        'Commit msg',
+        './myFilePath/test',
+        'Hello world!',
+        '123',
+        'repoName',
+        'branchName'
+      );
+      expect(res).toMatchSnapshot();
+    });
+    it('should be get the commit obj formated (file to create)', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => null),
+      }));
+
+      const res = await vstsHelper.getVSTSCommitObj(
+        'Commit msg',
+        './myFilePath/test',
+        'Hello world!',
+        '123',
+        'repoName',
+        'branchName'
+      );
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('getFile', () => {
+    it('should return null error GitItemNotFoundException', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => ({
+          readable: true,
+          read: jest.fn(() =>
+            Buffer.from('{"typeKey": "GitItemNotFoundException"}').toString(
+              'base64'
+            )
+          ),
+        })),
+      }));
+
+      const res = await vstsHelper.getFile(
+        '123',
+        'repoName',
+        './myFilePath/test',
+        'branchName'
+      );
+      expect(res).toBeNull();
+    });
+
+    it('should return null error GitUnresolvableToCommitException', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => ({
+          readable: true,
+          read: jest.fn(() =>
+            Buffer.from(
+              '{"typeKey": "GitUnresolvableToCommitException"}'
+            ).toString('base64')
+          ),
+        })),
+      }));
+
+      const res = await vstsHelper.getFile(
+        '123',
+        'repoName',
+        './myFilePath/test',
+        'branchName'
+      );
+      expect(res).toBeNull();
+    });
+
+    it('should return the file content because it is not a json', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => ({
+          readable: true,
+          read: jest.fn(() =>
+            Buffer.from('{"hello"= "test"}').toString('base64')
+          ),
+        })),
+      }));
+
+      const res = await vstsHelper.getFile(
+        '123',
+        'repoName',
+        './myFilePath/test',
+        'branchName'
+      );
+      expect(res).toMatchSnapshot();
+    });
+
+    it('should return null because the file is not readable', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getItemText: jest.fn(() => ({
+          readable: false,
+        })),
+      }));
+
+      const res = await vstsHelper.getFile(
+        '123',
+        'repoName',
+        './myFilePath/test',
+        'branchName'
+      );
+      expect(res).toBeNull();
+    });
+  });
+
+  describe('max4000Chars', () => {
+    it('should be the same', () => {
+      const res = vstsHelper.max4000Chars('Hello');
+      expect(res).toMatchSnapshot();
+    });
+    it('should be truncated', () => {
+      let str = '';
+      for (let i = 0; i < 5000; i += 1) {
+        str += 'a';
+      }
+      const res = vstsHelper.max4000Chars(str);
+      expect(res.length).toBe(3999);
+    });
+  });
+
+  describe('getRenovatePRFormat', () => {
+    it('should be formated (closed)', () => {
+      const res = vstsHelper.getRenovatePRFormat({ status: 2 });
+      expect(res).toMatchSnapshot();
+    });
+
+    it('should be formated (closed v2)', () => {
+      const res = vstsHelper.getRenovatePRFormat({ status: 3 });
+      expect(res).toMatchSnapshot();
+    });
+
+    it('should be formated (not closed)', () => {
+      const res = vstsHelper.getRenovatePRFormat({ status: 1 });
+      expect(res).toMatchSnapshot();
+    });
+
+    it('should be formated (isUnmergeable)', () => {
+      const res = vstsHelper.getRenovatePRFormat({ mergeStatus: 2 });
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('getCommitDetails', () => {
+    it('should get commit details', async () => {
+      gitApi.mockImplementationOnce(() => ({
+        getCommit: jest.fn(() => ({
+          parents: ['123456'],
+        })),
+      }));
+      const res = await vstsHelper.getCommitDetails('123', '123456');
+      expect(res).toMatchSnapshot();
+    });
+  });
+});
diff --git a/test/workers/pr/__snapshots__/index.spec.js.snap b/test/workers/pr/__snapshots__/index.spec.js.snap
index 91ae1f659f..ff05c02f7c 100644
--- a/test/workers/pr/__snapshots__/index.spec.js.snap
+++ b/test/workers/pr/__snapshots__/index.spec.js.snap
@@ -97,3 +97,29 @@ This PR has been generated by [Renovate Bot](https://renovateapp.com).",
 `;
 
 exports[`workers/pr ensurePr should return unmodified existing PR 1`] = `Array []`;
+
+exports[`workers/pr ensurePr should strip HTML PR for vsts 1`] = `
+Array [
+  "renovate/dummy-1.x",
+  "Update dependency dummy to v1.1.0",
+  "This Pull Request updates dependency [dummy](https://github.com/renovateapp/dummy) from \`v1.0.0\` to \`v1.1.0\`
+
+
+### Commits
+
+
+**renovateapp/dummy**
+
+#### 1.1.0
+-   [\`abcdefg\`](https://github.com/renovateapp/dummy/commit/abcdefghijklmnopqrstuvwxyz) foo [#3](https://github.com/renovateapp/dummy/issues/3)
+
+
+
+
+
+---
+
+This PR has been generated by [Renovate Bot](https://renovateapp.com).",
+  Array [],
+]
+`;
diff --git a/test/workers/pr/index.spec.js b/test/workers/pr/index.spec.js
index 07d60b4bac..c5b17ec260 100644
--- a/test/workers/pr/index.spec.js
+++ b/test/workers/pr/index.spec.js
@@ -138,6 +138,15 @@ describe('workers/pr', () => {
         -1
       );
     });
+    it('should strip HTML PR for vsts', async () => {
+      platform.getBranchStatus.mockReturnValueOnce('success');
+      config.prCreation = 'status-success';
+      config.isVsts = true;
+      const pr = await prWorker.ensurePr(config);
+      expect(pr).toMatchObject({ displayNumber: 'New Pull Request' });
+      expect(platform.createPr.mock.calls[0]).toMatchSnapshot();
+      expect(platform.createPr.mock.calls[0][2].indexOf('<details>')).toBe(-1);
+    });
     it('should delete branch and return null if creating PR fails', async () => {
       platform.getBranchStatus.mockReturnValueOnce('success');
       platform.createPr = jest.fn();
diff --git a/test/workers/repository/init/apis.spec.js b/test/workers/repository/init/apis.spec.js
index 6b0feabfe9..12964958d7 100644
--- a/test/workers/repository/init/apis.spec.js
+++ b/test/workers/repository/init/apis.spec.js
@@ -26,5 +26,17 @@ describe('workers/repository/init/apis', () => {
       glGot.mockReturnValueOnce({ body: {} });
       await initApis(config, 'some-token');
     });
+    it('runs vsts', async () => {
+      config.platform = 'vsts';
+      config.repository = 'some/name';
+      // config.endpoint = 'https://fabrikam.visualstudio.com/DefaultCollection';
+      try {
+        await initApis(config, 'some-token');
+      } catch (error) {
+        expect(error.message).toBe(
+          'You need an endpoint with vsts. Something like this: https://{instance}.VisualStudio.com/{collection} (https://fabrikam.visualstudio.com/DefaultCollection)'
+        );
+      }
+    });
   });
 });
diff --git a/yarn.lock b/yarn.lock
index 2c118a1c38..aa77a5cf44 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3,8 +3,8 @@
 
 
 "@semantic-release/commit-analyzer@^3.0.1":
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/@semantic-release/commit-analyzer/-/commit-analyzer-3.0.6.tgz#3020ca7030658f3f52fef14c78f7fcccb8a1b33a"
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@semantic-release/commit-analyzer/-/commit-analyzer-3.0.7.tgz#dc955444a6d3d2ae9b8e21f90c2c80c4e9142b2f"
   dependencies:
     "@semantic-release/error" "^2.0.0"
     conventional-changelog-angular "^1.4.0"
@@ -14,18 +14,18 @@
     pify "^3.0.0"
 
 "@semantic-release/condition-travis@^6.0.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@semantic-release/condition-travis/-/condition-travis-6.1.0.tgz#7962c728f4c19389b57759c7ff9ee08df9b15795"
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@semantic-release/condition-travis/-/condition-travis-6.1.1.tgz#9a86e843b7d533ecfa5835d7a1534682ee6085a7"
   dependencies:
     "@semantic-release/error" "^2.0.0"
-    github "^11.0.0"
+    github "^12.0.0"
     parse-github-repo-url "^1.4.1"
     semver "^5.0.3"
     travis-deploy-once "^3.0.0"
 
 "@semantic-release/error@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@semantic-release/error/-/error-2.0.0.tgz#f156ecd509f5288c48bc7425a8abe22f975d1f8b"
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@semantic-release/error/-/error-2.1.0.tgz#44771f676f5b148da309111285a97901aa95a6e0"
 
 "@semantic-release/last-release-npm@^2.0.0":
   version "2.0.2"
@@ -106,8 +106,8 @@ agentkeepalive@^3.3.0:
     humanize-ms "^1.2.1"
 
 ajv-keywords@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0"
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762"
 
 ajv@^4.9.1:
   version "4.11.8"
@@ -509,9 +509,9 @@ boom@5.x.x:
   dependencies:
     hoek "4.x.x"
 
-boxen@^1.0.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.2.1.tgz#0f11e7fe344edb9397977fc13ede7f64d956481d"
+boxen@^1.0.0, boxen@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.2.2.tgz#3f1d4032c30ffea9d4b02c322eaf2ea741dcbce5"
   dependencies:
     ansi-align "^2.0.0"
     camelcase "^4.0.0"
@@ -569,7 +569,25 @@ bunyan@1.8.12:
     mv "~2"
     safe-json-stringify "~1"
 
-cacache@^9.2.9, cacache@~9.2.9:
+cacache@^9.2.9:
+  version "9.3.0"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-9.3.0.tgz#9cd58f2dd0b8c8cacf685b7067b416d6d3cf9db1"
+  dependencies:
+    bluebird "^3.5.0"
+    chownr "^1.0.1"
+    glob "^7.1.2"
+    graceful-fs "^4.1.11"
+    lru-cache "^4.1.1"
+    mississippi "^1.3.0"
+    mkdirp "^0.5.1"
+    move-concurrently "^1.0.1"
+    promise-inflight "^1.0.1"
+    rimraf "^2.6.1"
+    ssri "^4.1.6"
+    unique-filename "^1.1.0"
+    y18n "^3.2.1"
+
+cacache@~9.2.9:
   version "9.2.9"
   resolved "https://registry.yarnpkg.com/cacache/-/cacache-9.2.9.tgz#f9d7ffe039851ec94c28290662afa4dd4bb9e8dd"
   dependencies:
@@ -664,7 +682,7 @@ chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
-chalk@2.3.0:
+chalk@2.3.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
   dependencies:
@@ -672,14 +690,6 @@ chalk@2.3.0:
     escape-string-regexp "^1.0.5"
     supports-color "^4.0.0"
 
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e"
-  dependencies:
-    ansi-styles "^3.1.0"
-    escape-string-regexp "^1.0.5"
-    supports-color "^4.0.0"
-
 changelog@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/changelog/-/changelog-1.4.1.tgz#82eab50891fb6b8a150a176e48654daa1cfc845c"
@@ -877,8 +887,8 @@ contains-path@^0.1.0:
   resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
 
 content-type-parser@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.1.tgz#c3e56988c53c65127fb46d4032a3a900246fdc94"
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.2.tgz#caabe80623e63638b2502fd4c7f12ff4ce2352e7"
 
 conventional-changelog-angular@^1.4.0:
   version "1.5.1"
@@ -1058,7 +1068,7 @@ dateformat@^1.0.11, dateformat@^1.0.12:
     get-stdin "^4.0.1"
     meow "^3.3.0"
 
-debug@2, debug@^2.2.0, debug@^2.4.1, debug@^2.6.3, debug@^2.6.8:
+debug@2, debug@^2.2.0, debug@^2.4.1, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
@@ -1070,7 +1080,7 @@ debug@2.2.0:
   dependencies:
     ms "0.7.1"
 
-debug@^3.0.1:
+debug@^3.0.1, debug@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   dependencies:
@@ -1166,8 +1176,8 @@ dezalgo@^1.0.0, dezalgo@~1.0.3:
     wrappy "1"
 
 diff@^3.2.0:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.1.tgz#aa8567a6eed03c531fc89d3f711cd0e5259dec75"
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c"
 
 doctrine@1.5.0:
   version "1.5.0"
@@ -1595,6 +1605,12 @@ follow-redirects@0.0.7:
     debug "^2.2.0"
     stream-consume "^0.1.0"
 
+follow-redirects@1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.5.tgz#ffd3e14cbdd5eaa72f61b6368c1f68516c2a26cc"
+  dependencies:
+    debug "^2.6.9"
+
 for-in@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -1855,6 +1871,15 @@ github@^11.0.0:
     mime "^1.2.11"
     netrc "^0.1.4"
 
+github@^12.0.0:
+  version "12.0.1"
+  resolved "https://registry.yarnpkg.com/github/-/github-12.0.1.tgz#4f7467434d8d01152782e669e925b3115aa0b219"
+  dependencies:
+    follow-redirects "1.2.5"
+    https-proxy-agent "^2.1.0"
+    mime "^2.0.3"
+    netrc "^0.1.4"
+
 github@~0.1.10:
   version "0.1.16"
   resolved "https://registry.yarnpkg.com/github/-/github-0.1.16.tgz#895d2a85b0feb7980d89ac0ce4f44dcaa03f17b5"
@@ -1900,6 +1925,12 @@ glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.2:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+global-dirs@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.0.tgz#10d34039e0df04272e262cf24224f7209434df4f"
+  dependencies:
+    ini "^1.3.4"
+
 global-modules@1.0.0, global-modules@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@@ -2109,14 +2140,14 @@ hosted-git-info@^2.1.4, hosted-git-info@^2.4.2, hosted-git-info@~2.5.0:
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c"
 
 html-encoding-sniffer@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.1.tgz#79bf7a785ea495fe66165e734153f363ff5437da"
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8"
   dependencies:
     whatwg-encoding "^1.0.1"
 
 http-cache-semantics@^3.7.3:
-  version "3.7.3"
-  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.7.3.tgz#2f35c532ecd29f1e5413b9af833b724a3c6f7f72"
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.0.tgz#1e3ce248730e189ac692a6697b9e3fdea2ff8da3"
 
 http-proxy-agent@^2.0.0:
   version "2.0.0"
@@ -2149,7 +2180,7 @@ https-proxy-agent@^1.0.0:
     debug "2"
     extend "3"
 
-https-proxy-agent@^2.0.0:
+https-proxy-agent@^2.0.0, https-proxy-agent@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.1.0.tgz#1391bee7fd66aeabc0df2a1fa90f58954f43e443"
   dependencies:
@@ -2162,11 +2193,7 @@ humanize-ms@^1.2.1:
   dependencies:
     ms "^2.0.0"
 
-iconv-lite@0.4.13:
-  version "0.4.13"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2"
-
-iconv-lite@^0.4.17, iconv-lite@~0.4.13:
+iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
   version "0.4.19"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
 
@@ -2174,15 +2201,15 @@ iferr@^0.1.5, iferr@~0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
 
-ignore-walk@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.0.tgz#e407919edee5c47c63473b319bfe3ea4a771a57e"
+ignore-walk@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
   dependencies:
     minimatch "^3.0.4"
 
 ignore@^3.3.3:
-  version "3.3.5"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.5.tgz#c4e715455f6073a8d7e5dae72d2fc9d71663dba6"
+  version "3.3.7"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
 
 import-from@^2.1.0:
   version "2.1.0"
@@ -2270,8 +2297,8 @@ is-arrayish@^0.2.1:
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
 
 is-buffer@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc"
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
 
 is-builtin-module@^1.0.0:
   version "1.0.0"
@@ -2331,6 +2358,13 @@ is-glob@^2.0.0, is-glob@^2.0.1:
   dependencies:
     is-extglob "^1.0.0"
 
+is-installed-globally@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
 is-my-json-valid@^2.12.4:
   version "2.16.1"
   resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz#5a846777e2c2620d1e69104e5d3a03b1f6088f11"
@@ -2463,17 +2497,17 @@ isstream@~0.1.2:
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
 
 istanbul-api@^1.1.1:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.1.14.tgz#25bc5701f7c680c0ffff913de46e3619a3a6e680"
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.2.1.tgz#0c60a0515eb11c7d65c6b50bba2c6e999acd8620"
   dependencies:
     async "^2.1.4"
     fileset "^2.0.2"
     istanbul-lib-coverage "^1.1.1"
-    istanbul-lib-hook "^1.0.7"
-    istanbul-lib-instrument "^1.8.0"
-    istanbul-lib-report "^1.1.1"
-    istanbul-lib-source-maps "^1.2.1"
-    istanbul-reports "^1.1.2"
+    istanbul-lib-hook "^1.1.0"
+    istanbul-lib-instrument "^1.9.1"
+    istanbul-lib-report "^1.1.2"
+    istanbul-lib-source-maps "^1.2.2"
+    istanbul-reports "^1.1.3"
     js-yaml "^3.7.0"
     mkdirp "^0.5.1"
     once "^1.4.0"
@@ -2482,15 +2516,15 @@ istanbul-lib-coverage@^1.0.1, istanbul-lib-coverage@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da"
 
-istanbul-lib-hook@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.0.7.tgz#dd6607f03076578fe7d6f2a630cf143b49bacddc"
+istanbul-lib-hook@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.1.0.tgz#8538d970372cb3716d53e55523dd54b557a8d89b"
   dependencies:
     append-transform "^0.4.0"
 
-istanbul-lib-instrument@^1.4.2, istanbul-lib-instrument@^1.7.5, istanbul-lib-instrument@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.8.0.tgz#66f6c9421cc9ec4704f76f2db084ba9078a2b532"
+istanbul-lib-instrument@^1.4.2, istanbul-lib-instrument@^1.7.5, istanbul-lib-instrument@^1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.9.1.tgz#250b30b3531e5d3251299fdd64b0b2c9db6b558e"
   dependencies:
     babel-generator "^6.18.0"
     babel-template "^6.16.0"
@@ -2500,28 +2534,28 @@ istanbul-lib-instrument@^1.4.2, istanbul-lib-instrument@^1.7.5, istanbul-lib-ins
     istanbul-lib-coverage "^1.1.1"
     semver "^5.3.0"
 
-istanbul-lib-report@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz#f0e55f56655ffa34222080b7a0cd4760e1405fc9"
+istanbul-lib-report@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.2.tgz#922be27c13b9511b979bd1587359f69798c1d425"
   dependencies:
     istanbul-lib-coverage "^1.1.1"
     mkdirp "^0.5.1"
     path-parse "^1.0.5"
     supports-color "^3.1.2"
 
-istanbul-lib-source-maps@^1.1.0, istanbul-lib-source-maps@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.1.tgz#a6fe1acba8ce08eebc638e572e294d267008aa0c"
+istanbul-lib-source-maps@^1.1.0, istanbul-lib-source-maps@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.2.tgz#750578602435f28a0c04ee6d7d9e0f2960e62c1c"
   dependencies:
-    debug "^2.6.3"
+    debug "^3.1.0"
     istanbul-lib-coverage "^1.1.1"
     mkdirp "^0.5.1"
     rimraf "^2.6.1"
     source-map "^0.5.3"
 
-istanbul-reports@^1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.2.tgz#0fb2e3f6aa9922bd3ce45d05d8ab4d5e8e07bd4f"
+istanbul-reports@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.3.tgz#3b9e1e8defb6d18b1d425da8e8b32c5a163f2d10"
   dependencies:
     handlebars "^4.0.3"
 
@@ -2773,8 +2807,8 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
 
 jschardet@^1.4.2:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.5.1.tgz#c519f629f86b3a5bedba58a88d311309eec097f9"
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.6.0.tgz#c7d1a71edcff2839db2f9ec30fc5d5ebd3c1a678"
 
 jsdom@^9.12.0:
   version "9.12.0"
@@ -3218,10 +3252,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@~4.1.1:
     yallist "^2.1.2"
 
 make-dir@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978"
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.1.0.tgz#19b4369fe48c116f53c2af95ad102c0e39e85d51"
   dependencies:
-    pify "^2.3.0"
+    pify "^3.0.0"
 
 make-fetch-happen@^2.4.13, make-fetch-happen@^2.5.0:
   version "2.5.0"
@@ -3310,6 +3344,10 @@ mime@^1.2.11:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
 
+mime@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.0.3.tgz#4353337854747c48ea498330dc034f9f4bbbcc0b"
+
 mimic-fn@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
@@ -3336,17 +3374,17 @@ minimist@~0.0.1:
   version "0.0.10"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
 
-minipass@^2.0.0, minipass@^2.0.2:
+minipass@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.2.1.tgz#5ada97538b1027b4cf7213432428578cb564011f"
   dependencies:
     yallist "^3.0.0"
 
-minizlib@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.0.3.tgz#d5c1abf77be154619952e253336eccab9b2a32f5"
+minizlib@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.0.4.tgz#8ebb51dd8bbe40b0126b5633dbb36b284a2f523c"
   dependencies:
-    minipass "^2.0.0"
+    minipass "^2.2.1"
 
 mississippi@^1.2.0, mississippi@^1.3.0, mississippi@~1.3.0:
   version "1.3.0"
@@ -3392,8 +3430,8 @@ moment@2.19.2:
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.2.tgz#8a7f774c95a64550b4c7ebd496683908f9419dbe"
 
 "moment@>= 2.9.0", moment@^2.10.6:
-  version "2.18.1"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
+  version "2.19.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167"
 
 move-concurrently@^1.0.1, move-concurrently@~1.0.1:
   version "1.0.1"
@@ -3571,10 +3609,10 @@ npm-lifecycle@~1.0.3:
     validate-npm-package-name "^3.0.0"
 
 npm-packlist@^1.1.6, npm-packlist@~1.1.9:
-  version "1.1.9"
-  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.9.tgz#bd24a0b7a31a307315b07c2e54f4888f10577548"
+  version "1.1.10"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a"
   dependencies:
-    ignore-walk "^3.0.0"
+    ignore-walk "^3.0.1"
     npm-bundled "^1.0.1"
 
 npm-pick-manifest@^1.0.4:
@@ -3585,8 +3623,8 @@ npm-pick-manifest@^1.0.4:
     semver "^5.3.0"
 
 npm-profile@~2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-2.0.4.tgz#148070c0da22b512bf61a4a87758b957fdb4bbe7"
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-2.0.5.tgz#0e61b8f1611bd19d1eeff5e3d5c82e557da3b9d7"
   dependencies:
     aproba "^1.1.2"
     make-fetch-happen "^2.5.0"
@@ -3745,8 +3783,8 @@ number-is-nan@^1.0.0:
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
 
 "nwmatcher@>= 1.3.9 < 2.0.0":
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.2.tgz#c5e545ab40d22a56b0326531c4beaed7a888b3ea"
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.3.tgz#64348e3b3d80f035b40ac11563d278f8b72db89c"
 
 oauth-sign@~0.8.1, oauth-sign@~0.8.2:
   version "0.8.2"
@@ -4057,8 +4095,8 @@ pretty-format@^21.2.1:
     ansi-styles "^3.2.0"
 
 private@^0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1"
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
 
 process-nextick-args@~1.0.6:
   version "1.0.7"
@@ -4131,8 +4169,8 @@ q@1.4.1:
   resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e"
 
 q@^1.4.1:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
 
 qrcode-terminal@~0.11.0:
   version "0.11.0"
@@ -4155,8 +4193,8 @@ qs@~6.5.1:
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
 
 query-string@~5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.0.0.tgz#fbdf7004b4d2aff792f9871981b7a2794f555947"
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.0.1.tgz#6e2b86fe0e08aef682ecbe86e85834765402bd88"
   dependencies:
     decode-uri-component "^0.2.0"
     object-assign "^4.1.0"
@@ -4177,16 +4215,7 @@ randomatic@^1.1.3:
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-rc@^1.0.1, rc@^1.1.6:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95"
-  dependencies:
-    deep-extend "~0.4.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
-rc@^1.1.7:
+rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.2.tgz#d8ce9cb57e8d64d9c7badd9876c7c34cbe3c7077"
   dependencies:
@@ -4714,8 +4743,8 @@ sntp@1.x.x:
     hoek "2.x.x"
 
 sntp@2.x.x:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b"
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.1.0.tgz#2c6cec14fedc2222739caf9b5c3d85d1cc5a2cc8"
   dependencies:
     hoek "4.x.x"
 
@@ -4821,8 +4850,8 @@ stream-consume@^0.1.0:
   resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f"
 
 stream-each@^1.1.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.0.tgz#1e95d47573f580d814dc0ff8cd0f66f1ce53c991"
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd"
   dependencies:
     end-of-stream "^1.1.0"
     stream-shift "^1.0.0"
@@ -4925,8 +4954,8 @@ supports-color@^3.1.2:
     has-flag "^1.0.0"
 
 supports-color@^4.0.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e"
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b"
   dependencies:
     has-flag "^2.0.0"
 
@@ -4967,12 +4996,12 @@ tar@^2.0.0, tar@^2.2.1:
     inherits "2"
 
 tar@^4.0.0, tar@~4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-4.0.1.tgz#3f5b2e5289db30c2abe4c960f43d0d9fff96aaf0"
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.0.2.tgz#e8e22bf3eec330e5c616d415a698395e294e8fad"
   dependencies:
     chownr "^1.0.1"
-    minipass "^2.0.2"
-    minizlib "^1.0.3"
+    minipass "^2.2.1"
+    minizlib "^1.0.4"
     mkdirp "^0.5.0"
     yallist "^3.0.2"
 
@@ -5104,6 +5133,10 @@ tunnel-agent@~0.4.1:
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb"
 
+tunnel@0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.4.tgz#2d3785a158c174c9a16dc2c046ec5fc5f1742213"
+
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@@ -5118,6 +5151,13 @@ type-detect@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.3.tgz#0e3f2670b44099b0b46c284d136a7ef49c74c2ea"
 
+typed-rest-client@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/typed-rest-client/-/typed-rest-client-0.12.0.tgz#6376f5527f427da121dcafdfd7e41e1321e0720c"
+  dependencies:
+    tunnel "0.0.4"
+    underscore "1.8.3"
+
 typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
@@ -5151,6 +5191,10 @@ underscore.string@~2.2.0rc:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-2.2.1.tgz#d7c0fa2af5d5a1a67f4253daee98132e733f0f19"
 
+underscore@1.8.3, underscore@^1.8.3:
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
+
 unique-filename@^1.1.0, unique-filename@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3"
@@ -5181,7 +5225,21 @@ unzip-response@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
 
-update-notifier@^2.2.0, update-notifier@~2.2.0:
+update-notifier@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.3.0.tgz#4e8827a6bb915140ab093559d7014e3ebb837451"
+  dependencies:
+    boxen "^1.2.1"
+    chalk "^2.0.1"
+    configstore "^3.0.0"
+    import-lazy "^2.1.0"
+    is-installed-globally "^0.1.0"
+    is-npm "^1.0.0"
+    latest-version "^3.0.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+update-notifier@~2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.2.0.tgz#1b5837cf90c0736d88627732b661c138f86de72f"
   dependencies:
@@ -5237,6 +5295,14 @@ verror@1.10.0:
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
+vso-node-api@6.2.8-preview:
+  version "6.2.8-preview"
+  resolved "https://registry.yarnpkg.com/vso-node-api/-/vso-node-api-6.2.8-preview.tgz#99902e626c408716ab90b042705452c88ec1c2f0"
+  dependencies:
+    tunnel "0.0.4"
+    typed-rest-client "^0.12.0"
+    underscore "^1.8.3"
+
 walk@^2.3.9:
   version "2.3.9"
   resolved "https://registry.yarnpkg.com/walk/-/walk-2.3.9.tgz#31b4db6678f2ae01c39ea9fb8725a9031e558a7b"
@@ -5271,10 +5337,10 @@ webidl-conversions@^4.0.0:
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
 
 whatwg-encoding@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.1.tgz#3c6c451a198ee7aec55b1ec61d0920c67801a5f4"
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.3.tgz#57c235bc8657e914d24e1a397d3c82daee0a6ba3"
   dependencies:
-    iconv-lite "0.4.13"
+    iconv-lite "0.4.19"
 
 whatwg-url@^4.3.0:
   version "4.8.0"
@@ -5322,8 +5388,8 @@ wordwrap@~0.0.2:
   resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
 
 worker-farm@^1.3.1, worker-farm@~1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d"
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.1.tgz#8e9f4a7da4f3c595aa600903051b969390423fa1"
   dependencies:
     errno "^0.1.4"
     xtend "^4.0.1"
-- 
GitLab