From 8a841a7c8110b42f56ef82935e57f04093699c18 Mon Sep 17 00:00:00 2001
From: Vladimir Starkov <iamstarkov@users.noreply.github.com>
Date: Thu, 7 Feb 2019 20:04:23 +0100
Subject: [PATCH] feat(bitbucket): Bitbucket Server platform support (#2774)

Closes #2482
---
 docs/self-hosting.md                          |  47 +-
 lib/platform/bitbucket-server/README.md       |  43 ++
 .../bitbucket-server/bb-got-wrapper.js        |  51 ++
 lib/platform/bitbucket-server/index.js        | 598 ++++++++++++++++++
 lib/platform/bitbucket-server/utils.js        |  63 ++
 lib/platform/git/storage.js                   |   3 +-
 lib/platform/index.js                         |   1 +
 lib/util/host-rules.js                        |   1 +
 lib/workers/global/index.js                   |   1 +
 test/_fixtures/bitbucket-server/responses.js  | 395 ++++++++++++
 .../__snapshots__/index.spec.js.snap          |  82 +++
 test/platform/bitbucket-server/index.spec.js  | 288 +++++++++
 12 files changed, 1555 insertions(+), 18 deletions(-)
 create mode 100644 lib/platform/bitbucket-server/README.md
 create mode 100644 lib/platform/bitbucket-server/bb-got-wrapper.js
 create mode 100644 lib/platform/bitbucket-server/index.js
 create mode 100644 lib/platform/bitbucket-server/utils.js
 create mode 100644 test/_fixtures/bitbucket-server/responses.js
 create mode 100644 test/platform/bitbucket-server/__snapshots__/index.spec.js.snap
 create mode 100644 test/platform/bitbucket-server/index.spec.js

diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 5284bb0d87..e13fee8bcf 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -97,29 +97,42 @@ stringData:
 
 ## Authentication
 
-You need to select a repository user for `renovate` to assume the identity of,
-and generate a Personal Access Token. It's strongly recommended that you use a
-dedicated "bot" account for this to avoid user confusion and to avoid the
-Renovate bot mistaking changes you have made or PRs you have raised for its own.
+You need to select a user account for `renovate` to assume the identity of, and generate a Personal Access Token. It is recommended to be `@renovate-bot` if you are using a self-hosted server and can pick any username you want.
 
-You can find instructions for GitHub
-[here](https://help.github.com/articles/creating-an-access-token-for-command-line-use/)
-(select "repo" permissions)
+#### GitHub Enterprise
 
-You can find instructions for GitLab
-[here](https://docs.gitlab.com/ee/api/README.html#personal-access-tokens).
+First, [create a personal access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/) for the bot account (select "repo" permissions).
+Configure it either as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`.
 
-You can find instructions for Bitbucket AppPasswords [here](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html).
+#### GitLab CE/EE
 
-Note: you should also configure a GitHub token even if your source host is GitLab or Bitbucket, because Renovate will need to perform many queries to github.com in order to retrieve Release Notes.
+First, [create a personal access token](https://docs.gitlab.com/ee/api/README.html#personal-access-tokens) for the bot account.
+Configure it either as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`.
+Don't forget to configure `platform=gitlab` somewhere in config.
 
-You can find instructions for Azure DevOps
-[azureDevOps](https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/pats).
+#### Bitbucket Cloud
 
-This token needs to be configured via file, environment variable, or CLI. See
-[docs/configuration.md](configuration.md) for details. The simplest way is to expose it as `RENOVATE_TOKEN`.
+First, [create an AppPassword](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html) for the bot account.
+Configure it as `password` in your `config.js` file, or in environment variable `RENOVATE_PASSWORD`, or via CLI `--password=`.
+Also be sure to configure the `username` for your bot account too.
+Don't forget to configure `platform=bitbucket` somewhere in config.
 
-For Bitbucket, you can configure `RENOVATE_USERNAME` and `RENOVATE_PASSWORD`.
+#### Bitbucket Server
+
+Create a [Personal Access Token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html) for your bot account.
+Configure it as `password` in your `config.js` file, or in environment variable `RENOVATE_PASSWORD`, or via CLI `--password=`.
+Also configure the `username` for your bot account too, if you decided not to name it `@renovate-bot`.
+Don't forget to configure `platform=bitbucket-server` somewhere in config.
+
+#### Azure DevOps
+
+First, [create a personal access token](https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/pats) for the bot account.
+Configure it either as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`.
+Don't forget to configure `platform=azure` somewhere in config.
+
+## GitHub.com token for release notes
+
+If you are running on any platform except github.com, it's important to also configure GITHUB*COM_TOKEN containing a personal access token for github.com. This account can actually be \_any* account on GitHub, and needs only read-only access. It's used when fetching release notes for repositories in order to increase the hourly API limit.
 
 ## Usage
 
@@ -158,7 +171,7 @@ Most people will run Renovate via cron, e.g. once per hour. Here is an example b
 
 export PATH="/home/user/.yarn/bin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
 export RENOVATE_CONFIG_FILE="/home/user/renovate-config.js"
-export RENOVATE_TOKEN="**some-token**" # GitHub, GitLab, Azure DevOps or BitBucket
+export RENOVATE_TOKEN="**some-token**" # GitHub, GitLab, Azure DevOps
 export GITHUB_COM_TOKEN="**github-token**" # Delete this if using github.com
 
 # Renovate
diff --git a/lib/platform/bitbucket-server/README.md b/lib/platform/bitbucket-server/README.md
new file mode 100644
index 0000000000..57f738c614
--- /dev/null
+++ b/lib/platform/bitbucket-server/README.md
@@ -0,0 +1,43 @@
+# Bitbucket Server Support
+
+Bitbucket Server support is considered in "alpha" release status.
+
+## Unsupported platform features/concepts
+
+- Adding assignees to PRs not supported (does not seem to be a Bitbucket concept)
+- Adding/removing labels (Bitbucket limitation?)
+
+## Features requiring implementation
+
+- Creating issues not implemented yet, e.g. when there is a config error
+- Adding reviewers to PRs not implemented yet
+- Adding comments to PRs not implemented yet, e.g. when a PR has been edited or has a lockfile error
+
+## Testing
+
+If you want a test Bitbucket server locally rather than with your production server, [Atlassian's Bitbucket Server Docker image](https://hub.docker.com/r/atlassian/bitbucket-server) is really convenient.
+
+As per their instructions, the following commands bring up a new server:
+
+```
+docker volume create --name bitbucketVolume
+docker run -v bitbucketVolume:/var/atlassian/application-data/bitbucket --name="bitbucket" -d -p 7990:7990 -p 7999:7999 atlassian/bitbucket-server:5.12.3
+```
+
+Once it's running and initialized, the quickest way to testing with Renovate is:
+
+1. Create the admin user as prompted
+2. Create a new project and a repository for that project
+3. Make sure the repository has a package file in it for Renovate to find, e.g. `.nvmrc` or `package.json`
+4. Create a dedicated REnovate user `@renovate-bot` and grant it write access to the project
+5. Note down the password for `@renovate-bot` and use it in the Renovate CLI
+
+At this point you should have a project ready for Renovate, and the `@renovate-bot` account ready to run on it. You can then run like this:
+
+```
+yarn start --platform=bitbucket-server --endpoint=http://localhost:7990 --git-fs=http --username=renovate-bot --password=abc123456789! --log-level=debug --autodiscover=true
+```
+
+Remember that the above CLI parameters can also be exported to env if you prefer, e.g. `export RENOVATE_PLATFORM=bitbucket-server`, etc.
+
+You should then receive a "Configure Renovate" onboarding PR in the project.
diff --git a/lib/platform/bitbucket-server/bb-got-wrapper.js b/lib/platform/bitbucket-server/bb-got-wrapper.js
new file mode 100644
index 0000000000..c2f00a3fab
--- /dev/null
+++ b/lib/platform/bitbucket-server/bb-got-wrapper.js
@@ -0,0 +1,51 @@
+/* istanbul ignore file */
+
+const got = require('got');
+const URL = require('url');
+const hostRules = require('../../util/host-rules');
+
+let cache = {};
+
+const platform = 'bitbucket-server';
+
+async function get(path, options) {
+  const { host } = URL.parse(path);
+  const opts = {
+    json: true,
+    basic: false,
+    ...hostRules.find({ platform, host }),
+    ...options,
+  };
+  const url = URL.resolve(opts.endpoint, path);
+  const method = (opts.method || 'get').toLowerCase();
+  if (method === 'get' && cache[path]) {
+    logger.trace({ path }, 'Returning cached result');
+    return cache[path];
+  }
+  opts.headers = {
+    'user-agent': 'https://github.com/renovatebot/renovate',
+    'X-Atlassian-Token': 'no-check',
+    authorization: opts.token ? `Basic ${opts.token}` : undefined,
+    ...opts.headers,
+  };
+
+  const res = await got(url, opts);
+  // logger.debug(res.body);
+  if (method.toLowerCase() === 'get') {
+    cache[path] = res;
+  }
+  return res;
+}
+
+const helpers = ['get', 'post', 'put', 'patch', 'head', 'delete'];
+
+for (const x of helpers) {
+  get[x] = (url, opts) =>
+    get(url, Object.assign({}, opts, { method: x.toUpperCase() }));
+}
+
+get.reset = function reset() {
+  cache = {};
+};
+
+module.exports = get;
diff --git a/lib/platform/bitbucket-server/index.js b/lib/platform/bitbucket-server/index.js
new file mode 100644
index 0000000000..392b5b5a84
--- /dev/null
+++ b/lib/platform/bitbucket-server/index.js
@@ -0,0 +1,598 @@
+const url = require('url');
+const _ = require('lodash');
+
+const api = require('./bb-got-wrapper');
+const utils = require('./utils');
+const { appSlug } = require('../../config/app-strings');
+const hostRules = require('../../util/host-rules');
+const GitStorage = require('../git/storage');
+
+const platform = 'bitbucket-server';
+
+let config = {};
+
+module.exports = {
+  getRepos,
+  cleanRepo,
+  initRepo,
+  getRepoStatus,
+  getRepoForceRebase,
+  setBaseBranch,
+  // Search
+  getFileList,
+  // Branch
+  branchExists,
+  getAllRenovateBranches,
+  isBranchStale,
+  getBranchPr,
+  getBranchStatus,
+  getBranchStatusCheck,
+  setBranchStatus,
+  deleteBranch,
+  mergeBranch,
+  getBranchLastCommitTime,
+  // issue
+  findIssue,
+  ensureIssue,
+  ensureIssueClosing,
+  addAssignees,
+  addReviewers,
+  deleteLabel,
+  // Comments
+  ensureComment,
+  ensureCommentRemoval,
+  // PR
+  getPrList,
+  findPr,
+  createPr,
+  getPr,
+  getPrFiles,
+  updatePr,
+  mergePr,
+  getPrBody,
+  // file
+  commitFilesToBranch,
+  getFile,
+  // commits
+  getCommitMessages,
+  // vulnerability alerts
+  getVulnerabilityAlerts,
+};
+
+// Get all repositories that the user has access to
+async function getRepos(token, endpoint) {
+  logger.debug(`getRepos(${endpoint})`);
+  const opts = hostRules.find({ platform }, { token, endpoint });
+  // istanbul ignore next
+  if (!opts.token) {
+    throw new Error('No token found for getRepos');
+  }
+  hostRules.update({ ...opts, platform, default: true });
+  try {
+    const projects = await utils.accumulateValues('/rest/api/1.0/projects');
+    const repos = await Promise.all(
+      projects.map(({ key }) =>
+        // TODO: can we filter this by permission=REPO_WRITE?
+        utils.accumulateValues(`/rest/api/1.0/projects/${key}/repos`)
+      )
+    );
+    const result = _.flatten(repos).map(
+      r => `${r.project.key.toLowerCase()}/${r.name}`
+    );
+    logger.debug({ result }, 'result of getRepos()');
+    return result;
+  } catch (err) /* istanbul ignore next */ {
+    logger.error({ err }, `bitbucket getRepos error`);
+    throw err;
+  }
+}
+
+function cleanRepo() {
+  logger.debug(`cleanRepo()`);
+  if (config.storage) {
+    config.storage.cleanRepo();
+  }
+  api.reset();
+  config = {};
+}
+
+// Initialize GitLab by getting base branch
+async function initRepo({ repository, endpoint, gitFs, localDir }) {
+  logger.debug(
+    `initRepo("${JSON.stringify(
+      { repository, endpoint, gitFs, localDir },
+      null,
+      2
+    )}")`
+  );
+  const opts = hostRules.find({ platform }, { endpoint });
+  // istanbul ignore if
+  if (!(opts.username && opts.password)) {
+    throw new Error(
+      `No username/password found for Bitbucket repository ${repository}`
+    );
+  }
+  // istanbul ignore if
+  if (!opts.endpoint) {
+    throw new Error(`No endpoint found for Bitbucket Server`);
+  }
+  hostRules.update({ ...opts, platform, default: true });
+  api.reset();
+
+  const [projectKey, repositorySlug] = repository.split('/');
+  config = { projectKey, repositorySlug };
+
+  // Always gitFs
+  const { host } = url.parse(opts.endpoint);
+  const gitUrl = GitStorage.getUrl({
+    gitFs: gitFs || 'https',
+    auth: `${opts.username}:${opts.password}`,
+    host: `${host}/scm`,
+    repository,
+  });
+
+  config.storage = new GitStorage();
+  await config.storage.initRepo({
+    ...config,
+    localDir,
+    url: gitUrl,
+  });
+
+  const platformConfig = {};
+
+  try {
+    const info = (await api.get(
+      `/rest/api/1.0/projects/${config.projectKey}/repos/${
+        config.repositorySlug
+      }`
+    )).body;
+    platformConfig.privateRepo = info.is_private;
+    platformConfig.isFork = !!info.parent;
+    platformConfig.repoFullName = info.name;
+    config.owner = info.project.key;
+    logger.debug(`${repository} owner = ${config.owner}`);
+    config.defaultBranch = (await api.get(
+      `/rest/api/1.0/projects/${config.projectKey}/repos/${
+        config.repositorySlug
+      }/branches/default`
+    )).body.displayId;
+    config.baseBranch = config.defaultBranch;
+    config.mergeMethod = 'merge';
+  } catch (err) /* istanbul ignore next */ {
+    logger.debug(err);
+    if (err.statusCode === 404) {
+      throw new Error('not-found');
+    }
+    logger.info({ err }, 'Unknown Bitbucket initRepo error');
+    throw err;
+  }
+  delete config.prList;
+  delete config.fileList;
+  logger.debug(
+    { platformConfig },
+    `platformConfig for ${config.projectKey}/${config.repositorySlug}`
+  );
+  return platformConfig;
+}
+
+function getRepoForceRebase() {
+  logger.debug(`getRepoForceRebase()`);
+  // TODO if applicable
+  // This function should return true only if the user has enabled a setting on the repo that enforces PRs to be kept up to date with master
+  // In such cases we rebase Renovate branches every time they fall behind
+  // In GitHub this is part of "branch protection"
+  return false;
+}
+
+async function setBaseBranch(branchName = config.defaultBranch) {
+  config.baseBranch = branchName;
+  await config.storage.setBaseBranch(branchName);
+}
+
+// Search
+
+// Get full file list
+function getFileList(branchName = config.baseBranch) {
+  logger.debug(`getFileList(${branchName})`);
+  return config.storage.getFileList(branchName);
+}
+
+// Branch
+
+// Returns true if branch exists, otherwise false
+function branchExists(branchName) {
+  logger.debug(`branchExists(${branchName})`);
+  return config.storage.branchExists(branchName);
+}
+
+// Returns the Pull Request for a branch. Null if not exists.
+// TODO: coverage
+// istanbul ignore next
+async function getBranchPr(branchName) {
+  logger.debug(`getBranchPr(${branchName})`);
+  const existingPr = await findPr(branchName, null, 'open');
+  return existingPr ? getPr(existingPr.number) : null;
+}
+
+function getAllRenovateBranches(branchPrefix) {
+  logger.debug('getAllRenovateBranches');
+  return config.storage.getAllRenovateBranches(branchPrefix);
+}
+
+function isBranchStale(branchName) {
+  logger.debug(`isBranchStale(${branchName})`);
+  return config.storage.isBranchStale(branchName);
+}
+
+function commitFilesToBranch(
+  branchName,
+  files,
+  message,
+  parentBranch = config.baseBranch
+) {
+  logger.debug(
+    `commitFilesToBranch(${JSON.stringify(
+      { branchName, filesLength: files.length, message, parentBranch },
+      null,
+      2
+    )})`
+  );
+  return config.storage.commitFilesToBranch(
+    branchName,
+    files,
+    message,
+    parentBranch
+  );
+}
+
+function getFile(filePath, branchName) {
+  logger.debug(`getFile(${filePath}, ${branchName})`);
+  return config.storage.getFile(filePath, branchName);
+}
+
+async function deleteBranch(branchName, closePr = false) {
+  logger.debug(`deleteBranch(${branchName}, closePr=${closePr})`);
+  // TODO: coverage
+  // istanbul ignore next
+  if (closePr) {
+    // getBranchPr
+    const pr = await getBranchPr(branchName);
+    await api.post(
+      `/rest/api/1.0/projects/${config.projectKey}/repos/${
+        config.repositorySlug
+      }/pull-requests/${pr.number}/decline?version=${pr.version + 1}`
+    );
+  }
+  return config.storage.deleteBranch(branchName);
+}
+
+function mergeBranch(branchName) {
+  logger.debug(`mergeBranch(${branchName})`);
+  return config.storage.mergeBranch(branchName);
+}
+
+function getBranchLastCommitTime(branchName) {
+  logger.debug(`getBranchLastCommitTime(${branchName})`);
+  return config.storage.getBranchLastCommitTime(branchName);
+}
+
+// istanbul ignore next
+function getRepoStatus() {
+  return config.storage.getRepoStatus();
+}
+
+// Returns the combined status for a branch.
+// umbrella for status checks
+// TODO: coverage
+// istanbul ignore next
+async function getBranchStatus(branchName, requiredStatusChecks) {
+  logger.debug(
+    `getBranchStatus(${branchName}, requiredStatusChecks=${!!requiredStatusChecks})`
+  );
+  const prList = await getPrList();
+  const prForBranch = prList.find(x => x.branchName === branchName);
+
+  if (!prForBranch) {
+    logger.info(`There is no open PR for branch: ${branchName}`);
+    // do no harm
+    // TODO: is it correct way?
+    return 'failed';
+  }
+  logger.debug({ prForBranch }, 'PRFORBRANCH');
+
+  const res = await api.get(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prForBranch.number}/merge`
+  );
+
+  const { canMerge } = res.body;
+  return canMerge ? 'success' : 'failed';
+}
+
+// TODO: coverage
+// istanbul ignore next
+async function getBranchStatusCheck(branchName, context) {
+  logger.debug(`getBranchStatusCheck(${branchName}, context=${context})`);
+  const prList = await getPrList();
+  const prForBranch = prList.find(x => x.branchName === branchName);
+  if (!prForBranch) {
+    logger.info(`There is no open PR for branch: ${branchName}`);
+    // do no harm
+    // TODO is it right?
+    return null;
+  }
+  const res = await api.get(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prForBranch.number}/merge`
+  );
+
+  const { canMerge } = res.body;
+  return canMerge ? 'success' : 'failed';
+}
+
+// TODO: coverage
+// istanbul ignore next
+function setBranchStatus(branchName) {
+  logger.debug(`setBranchStatus(${branchName})`);
+  // TODO: Needs implementation
+  // This used when Renovate is adding its own status checks, such as for lock file failure or for unpublishSafe=true
+  // BB Server doesnt support it AFAIK
+}
+
+// Issue
+
+// function getIssueList() {
+//   logger.debug(`getIssueList()`);
+//   // TODO: Needs implementation
+//   // This is used by Renovate when creating its own issues, e.g. for deprecated package warnings, config error notifications, or "masterIssue"
+//   // BB Server doesnt have issues
+//   return [];
+// }
+
+// istanbul ignore next
+function findIssue(title) {
+  logger.debug(`findIssue(${title})`);
+  // TODO: Needs implementation
+  // This is used by Renovate when creating its own issues, e.g. for deprecated package warnings, config error notifications, or "masterIssue"
+  // BB Server doesnt have issues
+  return null;
+}
+
+// istanbul ignore next
+function ensureIssue(title, body) {
+  logger.debug(`ensureIssue(${title}, body={${body}})`);
+  // TODO: Needs implementation
+  // This is used by Renovate when creating its own issues, e.g. for deprecated package warnings, config error notifications, or "masterIssue"
+  // BB Server doesnt have issues
+  return null;
+}
+
+// istanbul ignore next
+function ensureIssueClosing(title) {
+  logger.debug(`ensureIssueClosing(${title})`);
+  // TODO: Needs implementation
+  // This is used by Renovate when creating its own issues, e.g. for deprecated package warnings, config error notifications, or "masterIssue"
+  // BB Server doesnt have issues
+}
+
+// eslint-disable-next-line no-unused-vars
+function addAssignees(iid, assignees) {
+  logger.debug(`addAssignees(${iid})`);
+  // TODO: Needs implementation
+  // Currently Renovate does "Create PR" and then "Add assignee" as a two-step process, with this being the second step.
+  // BB Server doesnt support assignees
+}
+
+// eslint-disable-next-line no-unused-vars
+function addReviewers(iid, reviewers) {
+  logger.debug(`addReviewers(${iid})`);
+  // TODO: Needs implementation
+  // Only applicable if Bitbucket supports the concept of "reviewers"
+}
+
+// eslint-disable-next-line no-unused-vars
+function deleteLabel(issueNo, label) {
+  logger.debug(`deleteLabel(${issueNo})`);
+  // TODO: Needs implementation
+  // Only used for the "request Renovate to rebase a PR using a label" feature
+}
+
+// eslint-disable-next-line no-unused-vars
+function ensureComment(issueNo, topic, content) {
+  logger.debug(`ensureComment(${issueNo})`);
+  // TODO: Needs implementation
+  // Used when Renovate needs to add comments to a PR, such as lock file errors, PR modified notifications, etc.
+}
+
+// eslint-disable-next-line no-unused-vars
+function ensureCommentRemoval(issueNo, topic) {
+  logger.debug(`ensureCommentRemoval(${issueNo})`);
+  // TODO: Needs implementation
+  // Used when Renovate needs to add comments to a PR, such as lock file errors, PR modified notifications, etc.
+}
+
+// TODO: coverage
+// istanbul ignore next
+async function getPrList() {
+  logger.debug(`getPrList()`);
+  if (!config.prList) {
+    const values = await utils.accumulateValues(
+      `/rest/api/1.0/projects/${config.projectKey}/repos/${
+        config.repositorySlug
+      }/pull-requests?state=OPEN`
+    );
+
+    config.prList = values.map(utils.prInfo);
+    logger.info({ length: config.prList.length }, 'Retrieved Pull Requests');
+  } else {
+    logger.debug('returning cached PR list');
+  }
+  return config.prList;
+}
+
+// TODO: coverage
+// istanbul ignore next
+const isRelevantPr = (branchName, prTitle, states) => p =>
+  p.branchName === branchName &&
+  (!prTitle || p.title === prTitle) &&
+  states.includes(p.state);
+
+// TODO: coverage
+// istanbul ignore next
+async function findPr(
+  branchName,
+  prTitle,
+  inputStates = utils.prStates.all,
+  refreshCache
+) {
+  logger.debug(`findPr(${branchName})`);
+  let states;
+  // istanbul ignore if
+  if (inputStates === '!open') {
+    states = utils.prStates.notOpen;
+  } else {
+    states = inputStates;
+  }
+  logger.debug(`findPr(${branchName}, "${prTitle}", "${states}")`);
+  const prList = await getPrList({ refreshCache });
+  const pr = prList.find(isRelevantPr(branchName, prTitle, states));
+  if (pr) {
+    logger.debug(`Found PR #${pr.number}`);
+  } else {
+    logger.debug(`DID NOT Found PR from branch #${branchName}`);
+  }
+  return pr;
+}
+
+// Pull Request
+
+async function createPr(
+  branchName,
+  title,
+  description,
+  labels,
+  useDefaultBranch
+) {
+  logger.debug(`createPr(${branchName}, title=${title})`);
+  const base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
+
+  const body = {
+    title,
+    description,
+    fromRef: {
+      id: `refs/heads/${branchName}`,
+    },
+    toRef: {
+      id: `refs/heads/${base}`,
+    },
+  };
+
+  const prInfoRes = await api.post(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests`,
+    { body }
+  );
+
+  const pr = {
+    id: prInfoRes.body.id,
+    displayNumber: `Pull Request #${prInfoRes.body.id}`,
+    ...utils.prInfo(prInfoRes.body),
+  };
+
+  // istanbul ignore if
+  if (config.prList) {
+    config.prList.push(pr);
+  }
+
+  return pr;
+}
+
+// Gets details for a PR
+async function getPr(prNo) {
+  logger.debug(`getPr(${prNo})`);
+  if (!prNo) {
+    return null;
+  }
+  const res = await api.get(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prNo}`
+  );
+
+  const pr = {
+    displayNumber: `Pull Request #${res.body.id}`,
+    ...utils.prInfo(res.body),
+  };
+
+  if (pr.state === 'open') {
+    const mergeRes = await api.get(
+      `/rest/api/1.0/projects/${config.projectKey}/repos/${
+        config.repositorySlug
+      }/pull-requests/${prNo}/merge`
+    );
+    pr.isConflicted = !!mergeRes.body.conflicted;
+    pr.canMerge = !!mergeRes.body.canMerge;
+    pr.canRebase = true;
+  }
+
+  return pr;
+}
+
+// Return a list of all modified files in a PR
+function getPrFiles(mrNo) {
+  logger.debug(`getPrFiles(${mrNo})`);
+  // TODO: Needs implementation
+  // Used only by Renovate if you want it to validate user PRs that contain modifications of the Renovate config
+  return [];
+}
+
+async function updatePr(prNo, title, description) {
+  logger.debug(`updatePr(${prNo}, title=${title})`);
+
+  const { version } = (await api.get(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prNo}`
+  )).body;
+
+  await api.put(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prNo}`,
+    { body: { title, description, version } }
+  );
+}
+
+async function mergePr(prNo) {
+  logger.debug(`mergePr(${prNo})`);
+  // TODO: Needs implementation
+  // Used for "automerge" feature
+  await api.post(
+    `/rest/api/1.0/projects/${config.projectKey}/repos/${
+      config.repositorySlug
+    }/pull-requests/${prNo}/merge`
+  );
+}
+
+function getPrBody(input) {
+  logger.debug(`getPrBody(${(input || '').split('\n')[0]})`);
+  // Remove any HTML we use
+  return input
+    .replace(/<\/?summary>/g, '**')
+    .replace(/<\/?details>/g, '')
+    .replace(new RegExp(`\n---\n\n.*?<!-- ${appSlug}-rebase -->.*?\n`), '')
+    .substring(0, 30000);
+}
+
+function getCommitMessages() {
+  logger.debug(`getCommitMessages()`);
+  return config.storage.getCommitMessages();
+}
+
+function getVulnerabilityAlerts() {
+  logger.debug(`getVulnerabilityAlerts()`);
+  return [];
+}
diff --git a/lib/platform/bitbucket-server/utils.js b/lib/platform/bitbucket-server/utils.js
new file mode 100644
index 0000000000..13d3c9ed55
--- /dev/null
+++ b/lib/platform/bitbucket-server/utils.js
@@ -0,0 +1,63 @@
+// SEE for the reference https://github.com/renovatebot/renovate/blob/c3e9e572b225085448d94aa121c7ec81c14d3955/lib/platform/bitbucket/utils.js
+const url = require('url');
+const api = require('./bb-got-wrapper');
+
+// TODO: Are the below states correct?
+const prStates = {
+  open: ['OPEN'],
+  notOpen: ['MERGED', 'DECLINED', 'SUPERSEDED'],
+  merged: ['MERGED'],
+  closed: ['DECLINED', 'SUPERSEDED'],
+  all: ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'],
+};
+
+const prInfo = pr => ({
+  version: pr.version,
+  number: pr.id,
+  body: pr.description,
+  branchName: pr.fromRef.displayId,
+  title: pr.title,
+  state: prStates.closed.includes(pr.state) ? 'closed' : pr.state.toLowerCase(),
+  createdAt: pr.created_on,
+});
+
+const addMaxLength = (inputUrl, limit = 100) => {
+  const { search, ...parsedUrl } = url.parse(inputUrl, true);
+  const maxedUrl = url.format({
+    ...parsedUrl,
+    query: { ...parsedUrl.query, limit },
+  });
+  return maxedUrl;
+};
+
+const accumulateValues = async (reqUrl, method = 'get', options, limit) => {
+  let accumulator = [];
+  let nextUrl = addMaxLength(reqUrl, limit);
+  const lowerCaseMethod = method.toLocaleLowerCase();
+
+  while (typeof nextUrl !== 'undefined') {
+    const { body } = await api[lowerCaseMethod](nextUrl, options);
+    accumulator = [...accumulator, ...body.values];
+    nextUrl = body.isLastPage
+      ? undefined
+      : url.format({
+          ...url.parse(nextUrl),
+          query: {
+            ...url.parse(nextUrl, true).query,
+            start: body.nextPageStart,
+          },
+        });
+  }
+
+  return accumulator;
+};
+
+module.exports = {
+  prStates,
+  // buildStates,
+  prInfo,
+  accumulateValues,
+  // files: filesEndpoint,
+  // isConflicted,
+  // commitForm,
+};
diff --git a/lib/platform/git/storage.js b/lib/platform/git/storage.js
index 8d485666a4..a3cd086240 100644
--- a/lib/platform/git/storage.js
+++ b/lib/platform/git/storage.js
@@ -286,7 +286,7 @@ function localName(branchName) {
   return branchName.replace(/^origin\//, '');
 }
 
-Storage.getUrl = ({ gitFs, auth, hostname, repository }) => {
+Storage.getUrl = ({ gitFs, auth, hostname, host, repository }) => {
   let protocol = gitFs || 'https';
   // istanbul ignore if
   if (protocol.toString() === 'true') {
@@ -299,6 +299,7 @@ Storage.getUrl = ({ gitFs, auth, hostname, repository }) => {
     protocol,
     auth,
     hostname,
+    host,
     pathname: repository + '.git',
   });
 };
diff --git a/lib/platform/index.js b/lib/platform/index.js
index 592f38e84e..8789f40864 100644
--- a/lib/platform/index.js
+++ b/lib/platform/index.js
@@ -1,6 +1,7 @@
 /* eslint-disable global-require */
 const platforms = new Map([
   ['bitbucket', require('./bitbucket')],
+  ['bitbucket-server', require('./bitbucket-server')],
   ['github', require('./github')],
   ['gitlab', require('./gitlab')],
   ['azure', require('./azure')],
diff --git a/lib/util/host-rules.js b/lib/util/host-rules.js
index 5494e965ca..0b711f66c4 100644
--- a/lib/util/host-rules.js
+++ b/lib/util/host-rules.js
@@ -2,6 +2,7 @@ const URL = require('url');
 
 const defaults = {
   bitbucket: { name: 'Bitbucket', endpoint: 'https://api.bitbucket.org/' },
+  'bitbucket-server': { name: 'Bitbucket Server' },
   github: { name: 'GitHub', endpoint: 'https://api.github.com/' },
   gitlab: { name: 'GitLab', endpoint: 'https://gitlab.com/api/v4/' },
   azure: { name: 'Azure DevOps' },
diff --git a/lib/workers/global/index.js b/lib/workers/global/index.js
index 601f2649fc..e7860e3e90 100644
--- a/lib/workers/global/index.js
+++ b/lib/workers/global/index.js
@@ -78,6 +78,7 @@ function getRepositoryConfig(globalConfig, repository) {
     is.string(repository) ? { repository } : repository
   );
   repoConfig.isBitbucket = repoConfig.platform === 'bitbucket';
+  repoConfig.isBitbucketServer = repoConfig.platform === 'bitbucket-server';
   repoConfig.isGitHub = repoConfig.platform === 'github';
   repoConfig.isGitLab = repoConfig.platform === 'gitlab';
   repoConfig.isAzure = repoConfig.platform === 'azure';
diff --git a/test/_fixtures/bitbucket-server/responses.js b/test/_fixtures/bitbucket-server/responses.js
new file mode 100644
index 0000000000..d5eae86cdb
--- /dev/null
+++ b/test/_fixtures/bitbucket-server/responses.js
@@ -0,0 +1,395 @@
+function generateRepo(projectKey, repositorySlug) {
+  let projectKeyLower = projectKey.toLowerCase();
+  return {
+    slug: repositorySlug,
+    id: 13076,
+    name: repositorySlug,
+    scmId: 'git',
+    state: 'AVAILABLE',
+    statusMessage: 'Available',
+    forkable: true,
+    project: {
+      key: projectKey,
+      id: 2900,
+      name: `${repositorySlug}'s name`,
+      public: false,
+      type: 'NORMAL',
+      links: {
+        self: [
+          { href: `https://stash.renovatebot.com/projects/${projectKey}` },
+        ],
+      },
+    },
+    public: false,
+    links: {
+      clone: [
+        {
+          href: `https://stash.renovatebot.com/scm/${projectKeyLower}/${repositorySlug}.git`,
+          name: 'http',
+        },
+        {
+          href: `ssh://git@stash.renovatebot.com:7999/${projectKeyLower}/${repositorySlug}.git`,
+          name: 'ssh',
+        },
+      ],
+      self: [
+        {
+          href: `https://stash.renovatebot.com/projects/${projectKey}/repos/${repositorySlug}/browse`,
+        },
+      ],
+    },
+  };
+}
+
+function generatePR(projectKey, repositorySlug) {
+  return {
+    id: 5,
+    version: 1,
+    title: 'title',
+    description: '* Line 1\r\n* Line 2',
+    state: 'OPEN',
+    open: true,
+    closed: false,
+    createdDate: 1547853840016,
+    updatedDate: 1547853840016,
+    fromRef: {
+      id: 'refs/heads/userName1/pullRequest5',
+      displayId: 'userName1/pullRequest5',
+      latestCommit: '55efc02b2ab13a43a66cf705f5faacfcc6a762b4',
+      // Removed this with the idea it's not needed
+      // repository: {},
+    },
+    toRef: {
+      id: 'refs/heads/master',
+      displayId: 'master',
+      latestCommit: '0d9c7726c3d628b7e28af234595cfd20febdbf8e',
+      // Removed this with the idea it's not needed
+      // repository: {},
+    },
+    locked: false,
+    author: {
+      user: {
+        name: 'userName1',
+        emailAddress: 'userName1@renovatebot.com',
+        id: 144846,
+        displayName: 'Renovate Bot',
+        active: true,
+        slug: 'userName1',
+        type: 'NORMAL',
+        links: {
+          self: [{ href: 'https://stash.renovatebot.com/users/userName1' }],
+        },
+      },
+      role: 'AUTHOR',
+      approved: false,
+      status: 'UNAPPROVED',
+    },
+    reviewers: [
+      {
+        user: {
+          name: 'userName2',
+          emailAddress: 'userName2@renovatebot.com',
+          id: 71155,
+          displayName: 'Renovate bot 2',
+          active: true,
+          slug: 'userName2',
+          type: 'NORMAL',
+          links: {
+            self: [{ href: 'https://stash.renovatebot.com/users/userName2' }],
+          },
+        },
+        role: 'REVIEWER',
+        approved: false,
+        status: 'UNAPPROVED',
+      },
+    ],
+    participants: [],
+    links: {
+      self: [
+        {
+          href: `https://stash.renovatebot.com/projects/${projectKey}/repos/${repositorySlug}/pull-requests/5`,
+        },
+      ],
+    },
+  };
+}
+
+module.exports = {
+  baseURL: "https://stash.renovatebot.com/",
+  '/rest/api/1.0/projects': {
+    size: 1,
+    limit: 100,
+    isLastPage: true,
+    values: [
+      {
+        key: 'SOME',
+        id: 1964,
+        name: 'Some',
+        public: false,
+        type: 'NORMAL',
+        links: {
+          self: [{ href: 'https://stash.renovatebot.com/projects/SOME' }],
+        },
+      },
+    ],
+    start: 0,
+  },
+  '/rest/api/1.0/projects/some/repos': {
+    size: 1,
+    limit: 25,
+    isLastPage: true,
+    values: [generateRepo('SOME', 'repo')],
+    start: 0,
+  },
+  '/rest/api/1.0/projects/some/repos/repo': generateRepo('SOME', 'repo'),
+  '/rest/api/1.0/projects/some/repos/repo/branches/default': {
+    displayId: 'master',
+  },
+  '/rest/api/1.0/projects/some/repos/repo/issues': {
+    // TODO - I'm not sure there is an issues link to provide
+    values: [],
+  },
+  '/rest/api/1.0/projects/some/repos/repo/pullrequests': {
+    values: [generatePR()],
+  },
+  '/rest/api/1.0/projects/some/repos/repo/pullrequests/5': generatePR(),
+  '/rest/api/1.0/projects/some/repos/repo/pullrequests/5/diff': {
+    fromHash: 'afdcf5e55dfce85055a146783434b0e2a81722c1',
+    toHash: '590e661bb8c189b5a4bee115b475c9f14bf112bd',
+    contextLines: 10,
+    whitespace: 'SHOW',
+    diffs: [
+      {
+        source: {
+          components: ['package.json'],
+          parent: '',
+          name: 'package.json',
+          extension: 'json',
+          toString: 'package.json',
+        },
+        destination: {
+          components: ['package.json'],
+          parent: '',
+          name: 'package.json',
+          extension: 'json',
+          toString: 'package.json',
+        },
+        hunks: [
+          {
+            sourceLine: 47,
+            sourceSpan: 18,
+            destinationLine: 47,
+            destinationSpan: 18,
+            segments: [
+              {
+                type: 'CONTEXT',
+                lines: [
+                  {
+                    source: 47,
+                    destination: 47,
+                    line: '    "webpack": "4.28.0"',
+                    truncated: false,
+                  },
+                  {
+                    source: 48,
+                    destination: 48,
+                    line: '  },',
+                    truncated: false,
+                  },
+                  {
+                    source: 49,
+                    destination: 49,
+                    line: '  "license": "MIT",',
+                    truncated: false,
+                  },
+                  {
+                    source: 50,
+                    destination: 50,
+                    line: '  "main": "dist/index.js",',
+                    truncated: false,
+                  },
+                  {
+                    source: 51,
+                    destination: 51,
+                    line: '  "module": "dist/index.es.js",',
+                    truncated: false,
+                  },
+                  {
+                    source: 52,
+                    destination: 52,
+                    line: '  "name": "removed-for-privacy",',
+                    truncated: false,
+                  },
+                  {
+                    source: 53,
+                    destination: 53,
+                    line: '  "publishConfig": {',
+                    truncated: false,
+                  },
+                  {
+                    source: 54,
+                    destination: 54,
+                    line: '    "registry": "https://npm.renovatebot.com/"',
+                    truncated: false,
+                  },
+                  {
+                    source: 55,
+                    destination: 55,
+                    line: '  },',
+                    truncated: false,
+                  },
+                  {
+                    source: 56,
+                    destination: 56,
+                    line: '  "scripts": {',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'REMOVED',
+                lines: [
+                  {
+                    source: 57,
+                    destination: 57,
+                    line:
+                      '    "build": "TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" webpack --config=webpack.config.prod.ts",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'ADDED',
+                lines: [
+                  {
+                    source: 58,
+                    destination: 57,
+                    line:
+                      '    "build": "npm run env TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" -- && webpack --config=webpack.config.prod.ts",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'CONTEXT',
+                lines: [
+                  {
+                    source: 58,
+                    destination: 58,
+                    line: '    "clean": "rimraf dist",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'REMOVED',
+                lines: [
+                  {
+                    source: 59,
+                    destination: 59,
+                    line:
+                      '    "dev": "TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" webpack --config=webpack.config.ts",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'ADDED',
+                lines: [
+                  {
+                    source: 60,
+                    destination: 59,
+                    line:
+                      '    "dev": "npm run env TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" -- && webpack --config=webpack.config.ts",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'CONTEXT',
+                lines: [
+                  {
+                    source: 60,
+                    destination: 60,
+                    line: '    "prepare": "npm run clean && npm run build",',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'REMOVED',
+                lines: [
+                  {
+                    source: 61,
+                    destination: 61,
+                    line:
+                      '    "start": "TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" webpack-dev-server --env.NODE_ENV=development --env.buildenv=stage"',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'ADDED',
+                lines: [
+                  {
+                    source: 62,
+                    destination: 61,
+                    line:
+                      '    "start": "npm run env TS_NODE_PROJECT=\\"tsconfig.webpack.json\\" -- && webpack-dev-server --env.NODE_ENV=development --env.buildenv=stage"',
+                    truncated: false,
+                  },
+                ],
+                truncated: false,
+              },
+              {
+                type: 'CONTEXT',
+                lines: [
+                  {
+                    source: 62,
+                    destination: 62,
+                    line: '  },',
+                    truncated: false,
+                  },
+                  {
+                    source: 63,
+                    destination: 63,
+                    line: '  "version": "0.0.1"',
+                    truncated: false,
+                  },
+                  { source: 64, destination: 64, line: '}', truncated: false },
+                ],
+                truncated: false,
+              },
+            ],
+            truncated: false,
+          },
+        ],
+        truncated: false,
+      },
+    ],
+    truncated: false,
+  },
+  '/rest/api/1.0/projects/some/repos/repo/pullrequests/5/commits': {
+    values: [{}],
+  },
+  '/rest/api/1.0/projects/some/repos/branches': {
+    isLastPage: true,
+    limit: 25,
+    size: 2,
+    start: 0,
+    values: [
+      { displayId: 'master', id: 'refs/heads/master' },
+      { displayId: 'branch', id: 'refs/heads/branch' },
+      { displayId: 'renovate/branch', id: 'refs/heads/renovate/branch' },
+      { displayId: 'renovate/upgrade', id: 'refs/heads/renovate/upgrade' },
+    ],
+  },
+};
diff --git a/test/platform/bitbucket-server/__snapshots__/index.spec.js.snap b/test/platform/bitbucket-server/__snapshots__/index.spec.js.snap
new file mode 100644
index 0000000000..9ed7ba0dcc
--- /dev/null
+++ b/test/platform/bitbucket-server/__snapshots__/index.spec.js.snap
@@ -0,0 +1,82 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`platform/bitbucket createPr() posts PR 1`] = `
+Array [
+  Array [
+    "/rest/api/1.0/projects/some/repos/repo/pull-requests",
+    Object {
+      "body": Object {
+        "description": "body",
+        "fromRef": Object {
+          "id": "refs/heads/branch",
+        },
+        "title": "title",
+        "toRef": Object {
+          "id": "refs/heads/master",
+        },
+      },
+    },
+  ],
+]
+`;
+
+exports[`platform/bitbucket getPr() gets a PR 1`] = `
+Object {
+  "body": "* Line 1
+* Line 2",
+  "branchName": "userName1/pullRequest5",
+  "canMerge": false,
+  "canRebase": true,
+  "createdAt": undefined,
+  "displayNumber": "Pull Request #5",
+  "isConflicted": false,
+  "number": 5,
+  "state": "open",
+  "title": "title",
+  "version": 1,
+}
+`;
+
+exports[`platform/bitbucket getPrBody() returns diff files 1`] = `"**foo**bartext"`;
+
+exports[`platform/bitbucket initRepo() works 1`] = `
+Object {
+  "isFork": false,
+  "privateRepo": undefined,
+  "repoFullName": "repo",
+}
+`;
+
+exports[`platform/bitbucket mergePr() posts Merge 1`] = `
+Array [
+  Array [
+    "/rest/api/1.0/projects/some/repos/repo/pull-requests/5/merge",
+  ],
+]
+`;
+
+exports[`platform/bitbucket setBaseBranch() updates file list 1`] = `
+Array [
+  Array [
+    "/rest/api/1.0/projects/some/repos/repo",
+  ],
+  Array [
+    "/rest/api/1.0/projects/some/repos/repo/branches/default",
+  ],
+]
+`;
+
+exports[`platform/bitbucket updatePr() puts PR 1`] = `
+Array [
+  Array [
+    "/rest/api/1.0/projects/some/repos/repo/pull-requests/5",
+    Object {
+      "body": Object {
+        "description": "body",
+        "title": "title",
+        "version": 1,
+      },
+    },
+  ],
+]
+`;
diff --git a/test/platform/bitbucket-server/index.spec.js b/test/platform/bitbucket-server/index.spec.js
new file mode 100644
index 0000000000..82eb417fb9
--- /dev/null
+++ b/test/platform/bitbucket-server/index.spec.js
@@ -0,0 +1,288 @@
+// eslint-disable-next-line no-unused-vars
+const URL = require('url');
+// eslint-disable-next-line no-unused-vars
+const responses = require('../../_fixtures/bitbucket-server/responses');
+
+describe('platform/bitbucket', () => {
+  let bitbucket;
+  let api;
+  let hostRules;
+  let GitStorage;
+  beforeEach(() => {
+    // reset module
+    jest.resetModules();
+    jest.mock('../../../lib/platform/bitbucket-server/bb-got-wrapper');
+    jest.mock('../../../lib/platform/git/storage');
+    hostRules = require('../../../lib/util/host-rules');
+    api = require('../../../lib/platform/bitbucket-server/bb-got-wrapper');
+    bitbucket = require('../../../lib/platform/bitbucket-server');
+    GitStorage = require('../../../lib/platform/git/storage');
+    GitStorage.mockImplementation(() => ({
+      initRepo: jest.fn(),
+      cleanRepo: jest.fn(),
+      getFileList: jest.fn(),
+      branchExists: jest.fn(() => true),
+      isBranchStale: jest.fn(() => false),
+      setBaseBranch: jest.fn(),
+      getBranchLastCommitTime: jest.fn(),
+      getAllRenovateBranches: jest.fn(),
+      getCommitMessages: jest.fn(),
+      getFile: jest.fn(),
+      commitFilesToBranch: jest.fn(),
+      mergeBranch: jest.fn(),
+      deleteBranch: jest.fn(),
+      getRepoStatus: jest.fn(),
+    }));
+
+    // clean up hostRules
+    hostRules.clear();
+    hostRules.update({
+      platform: 'bitbucket-server',
+      token: 'token',
+      username: 'username',
+      password: 'password',
+      endpoint: responses.baseURL,
+    });
+  });
+
+  afterEach(() => {
+    bitbucket.cleanRepo();
+  });
+
+  function initRepo() {
+    api.get.mockReturnValueOnce({
+      body: responses['/rest/api/1.0/projects/some/repos/repo'],
+    });
+    api.get.mockReturnValueOnce({
+      body:
+        responses['/rest/api/1.0/projects/some/repos/repo/branches/default'],
+    });
+    return bitbucket.initRepo({ repository: 'some/repo' });
+  }
+
+  describe('getRepos()', () => {
+    it('returns repos', async () => {
+      api.get
+        .mockReturnValueOnce({
+          body: responses['/rest/api/1.0/projects'],
+        })
+        .mockReturnValueOnce({
+          body: responses['/rest/api/1.0/projects/some/repos'],
+        });
+      expect(await bitbucket.getRepos()).toEqual(['some/repo']);
+    });
+  });
+
+  describe('initRepo()', () => {
+    it('works', async () => {
+      const res = await initRepo();
+      expect(res).toMatchSnapshot();
+    });
+  });
+
+  describe('repoForceRebase()', () => {
+    it('always return false, since bitbucket does not support force rebase', () => {
+      const actual = bitbucket.getRepoForceRebase();
+      const expected = false;
+      expect(actual).toBe(expected);
+    });
+  });
+
+  describe('setBaseBranch()', () => {
+    it('updates file list', async () => {
+      await initRepo();
+      await bitbucket.setBaseBranch('branch');
+      expect(api.get.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('getFileList()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.getFileList();
+    });
+  });
+
+  describe('branchExists()', () => {
+    describe('getFileList()', () => {
+      it('sends to gitFs', async () => {
+        await initRepo();
+        await bitbucket.branchExists();
+      });
+    });
+  });
+
+  describe('isBranchStale()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.isBranchStale();
+    });
+  });
+
+  describe('deleteBranch()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.deleteBranch('branch');
+    });
+  });
+
+  describe('mergeBranch()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.mergeBranch('branch');
+    });
+  });
+
+  describe('commitFilesToBranch()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.commitFilesToBranch('some-branch', [{}]);
+    });
+  });
+
+  describe('getFile()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.getFile();
+    });
+  });
+
+  describe('getAllRenovateBranches()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.getAllRenovateBranches();
+    });
+  });
+
+  describe('getBranchLastCommitTime()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.getBranchLastCommitTime();
+    });
+  });
+
+  describe('addAssignees()', () => {
+    it('does not throw', async () => {
+      await bitbucket.addAssignees(3, ['some']);
+    });
+  });
+
+  describe('addReviewers', () => {
+    it('does not throw', async () => {
+      await bitbucket.addReviewers(5, ['some']);
+    });
+  });
+
+  describe('deleteLAbel()', () => {
+    it('does not throw', async () => {
+      await bitbucket.deleteLabel(5, 'renovate');
+    });
+  });
+
+  describe('ensureComment()', () => {
+    it('does not throw', async () => {
+      await bitbucket.ensureComment(3, 'topic', 'content');
+    });
+  });
+
+  describe('ensureCommentRemoval()', () => {
+    it('does not throw', async () => {
+      await bitbucket.ensureCommentRemoval(3, 'topic');
+    });
+  });
+
+  describe('getPrList()', () => {
+    it('exists', () => {
+      expect(bitbucket.getPrList).toBeDefined();
+      // TODO
+    });
+  });
+
+  describe('findPr()', () => {
+    it('exists', () => {
+      expect(bitbucket.findPr).toBeDefined();
+      // TODO
+    });
+  });
+
+  describe('createPr()', () => {
+    it('posts PR', async () => {
+      await initRepo();
+      api.post.mockReturnValueOnce({
+        body: {
+          id: 5,
+          fromRef: { displayId: 'renovate/some-branch' },
+          state: 'OPEN',
+        },
+      });
+      const { id } = await bitbucket.createPr('branch', 'title', 'body');
+      expect(id).toBe(5);
+      expect(api.post.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('getPr()', () => {
+    // beforeEach(() => initRepo())
+    it('returns null for no prNo', async () => {
+      expect(await bitbucket.getPr()).toBe(null);
+    });
+    it('gets a PR', async () => {
+      api.get.mockReturnValueOnce({
+        body:
+          responses['/rest/api/1.0/projects/some/repos/repo/pullrequests/5'],
+      });
+      api.get.mockReturnValueOnce({
+        body: {
+          conflicted: false,
+        },
+      });
+      expect(await bitbucket.getPr(5)).toMatchSnapshot();
+    });
+  });
+
+  describe('getPrFiles()', () => {
+    it('returns empty files', async () => {
+      expect(await bitbucket.getPrFiles(5)).toHaveLength(0);
+    });
+  });
+
+  describe('updatePr()', () => {
+    it('puts PR', async () => {
+      await initRepo();
+      api.get.mockReturnValueOnce({ body: { version: 1 } });
+      await bitbucket.updatePr(5, 'title', 'body');
+      expect(api.put.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('mergePr()', () => {
+    it('posts Merge', async () => {
+      await initRepo();
+      await bitbucket.mergePr(5, 'branch');
+      expect(api.post.mock.calls).toMatchSnapshot();
+    });
+  });
+
+  describe('getPrBody()', () => {
+    it('returns diff files', () => {
+      expect(
+        bitbucket.getPrBody(
+          '<details><summary>foo</summary>bar</details>text<details>'
+        )
+      ).toMatchSnapshot();
+    });
+  });
+
+  describe('getCommitMessages()', () => {
+    it('sends to gitFs', async () => {
+      await initRepo();
+      await bitbucket.getCommitMessages();
+    });
+  });
+
+  describe('getVulnerabilityAlerts()', () => {
+    it('returns empty array', async () => {
+      expect(await bitbucket.getVulnerabilityAlerts()).toEqual([]);
+    });
+  });
+});
-- 
GitLab