Skip to content
Snippets Groups Projects
Select Git revision
21 results Searching

index.ts

Blame
  • index.ts 31.00 KiB
    import url, { URLSearchParams } from 'url';
    import delay from 'delay';
    
    import { RenovateConfig } from '../../config/common';
    import {
      REPOSITORY_CHANGED,
      REPOSITORY_DISABLED,
      REPOSITORY_NOT_FOUND,
    } from '../../constants/error-messages';
    import { PLATFORM_TYPE_BITBUCKET_SERVER } from '../../constants/platforms';
    import { PR_STATE_ALL, PR_STATE_OPEN } from '../../constants/pull-requests';
    import { logger } from '../../logger';
    import { BranchStatus } from '../../types';
    import * as hostRules from '../../util/host-rules';
    import { sanitize } from '../../util/sanitize';
    import { ensureTrailingSlash } from '../../util/url';
    import {
      BranchStatusConfig,
      CommitFilesConfig,
      CreatePRConfig,
      EnsureCommentConfig,
      EnsureIssueConfig,
      EnsureIssueResult,
      FindPRConfig,
      GotResponse,
      Issue,
      PlatformConfig,
      Pr,
      RepoConfig,
      RepoParams,
      VulnerabilityAlert,
    } from '../common';
    import GitStorage, { StatusResult } from '../git/storage';
    import { smartTruncate } from '../utils/pr-body';
    import { api } from './bb-got-wrapper';
    import * as utils from './utils';
    /*
     * Version: 5.3 (EOL Date: 15 Aug 2019)
     * See following docs for api information:
     * https://docs.atlassian.com/bitbucket-server/rest/5.3.0/bitbucket-rest.html
     * https://docs.atlassian.com/bitbucket-server/rest/5.3.0/bitbucket-build-rest.html
     *
     * See following page for uptodate supported versions
     * https://confluence.atlassian.com/support/atlassian-support-end-of-life-policy-201851003.html#AtlassianSupportEndofLifePolicy-BitbucketServer
     */
    
    interface BbsConfig {
      baseBranch: string;
      bbUseDefaultReviewers: boolean;
      defaultBranch: string;
      fileList: any[];
      mergeMethod: string;
      owner: string;
      prList: Pr[];
      projectKey: string;
      repository: string;
      repositorySlug: string;
      storage: GitStorage;
    
      prVersions: Map<number, number>;
    
      username: string;
    }
    
    let config: BbsConfig = {} as any;
    
    const defaults: any = {
      hostType: PLATFORM_TYPE_BITBUCKET_SERVER,
    };
    
    /* istanbul ignore next */
    function updatePrVersion(pr: number, version: number): number {
      const res = Math.max(config.prVersions.get(pr) || 0, version);
      config.prVersions.set(pr, res);
      return res;
    }
    
    export function initPlatform({
      endpoint,
      username,
      password,
    }: RenovateConfig): Promise<PlatformConfig> {
      if (!endpoint) {
        throw new Error('Init: You must configure a Bitbucket Server endpoint');
      }
      if (!(username && password)) {
        throw new Error(
          'Init: You must configure a Bitbucket Server username/password'
        );
      }
      // TODO: Add a connection check that endpoint/username/password combination are valid
      defaults.endpoint = ensureTrailingSlash(endpoint);
      api.setBaseUrl(defaults.endpoint);
      const platformConfig: PlatformConfig = {
        endpoint: defaults.endpoint,
      };
      return Promise.resolve(platformConfig);
    }
    
    // Get all repositories that the user has access to
    export async function getRepos(): Promise<string[]> {
      logger.debug('Autodiscovering Bitbucket Server repositories');
      try {
        const repos = await utils.accumulateValues(
          `./rest/api/1.0/repos?permission=REPO_WRITE&state=AVAILABLE`
        );
        const result = repos.map(
          (r: { project: { key: string }; slug: string }) =>
            `${r.project.key.toLowerCase()}/${r.slug}`
        );
        logger.debug({ result }, 'result of getRepos()');
        return result;
      } catch (err) /* istanbul ignore next */ {
        logger.error({ err }, `bitbucket getRepos error`);
        throw err;
      }
    }
    
    export function cleanRepo(): Promise<void> {
      logger.debug(`cleanRepo()`);
      if (config.storage) {
        config.storage.cleanRepo();
      }
      config = {} as any;
      return Promise.resolve();
    }
    
    // Initialize GitLab by getting base branch
    export async function initRepo({
      repository,
      gitPrivateKey,
      localDir,
      optimizeForDisabled,
      bbUseDefaultReviewers,
    }: RepoParams): Promise<RepoConfig> {
      logger.debug(
        `initRepo("${JSON.stringify({ repository, localDir }, null, 2)}")`
      );
      const opts = hostRules.find({
        hostType: defaults.hostType,
        url: defaults.endpoint,
      });
    
      const [projectKey, repositorySlug] = repository.split('/');
    
      if (optimizeForDisabled) {
        interface RenovateConfig {
          enabled: boolean;
        }
    
        interface FileData {
          isLastPage: boolean;
    
          lines: string[];
    
          size: number;
        }
    
        let renovateConfig: RenovateConfig;
        try {
          const { body } = await api.get<FileData>(
            `./rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/browse/renovate.json?limit=20000`
          );
          if (!body.isLastPage) {
            logger.warn('Renovate config to big: ' + body.size);
          } else {
            renovateConfig = JSON.parse(body.lines.join());
          }
        } catch {
          // Do nothing
        }
        if (renovateConfig && renovateConfig.enabled === false) {
          throw new Error(REPOSITORY_DISABLED);
        }
      }
    
      config = {
        projectKey,
        repositorySlug,
        gitPrivateKey,
        repository,
        prVersions: new Map<number, number>(),
        username: opts.username,
      } as any;
    
      /* istanbul ignore else */
      if (bbUseDefaultReviewers !== false) {
        logger.debug('Enable bitbucket default reviewer');
        config.bbUseDefaultReviewers = true;
      }
    
      const { host, pathname } = url.parse(defaults.endpoint!);
      const gitUrl = GitStorage.getUrl({
        protocol: defaults.endpoint!.split(':')[0],
        auth: `${opts.username}:${opts.password}`,
        host: `${host}${pathname}${
          pathname.endsWith('/') ? '' : /* istanbul ignore next */ '/'
        }scm`,
        repository,
      });
    
      config.storage = new GitStorage();
      await config.storage.initRepo({
        ...config,
        localDir,
        url: gitUrl,
      });
    
      try {
        const info = (
          await api.get(
            `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}`
          )
        ).body;
        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';
        const repoConfig: RepoConfig = {
          baseBranch: config.baseBranch,
          isFork: !!info.parent,
        };
        return repoConfig;
      } catch (err) /* istanbul ignore next */ {
        logger.debug(err);
        if (err.statusCode === 404) {
          throw new Error(REPOSITORY_NOT_FOUND);
        }
        logger.debug({ err }, 'Unknown Bitbucket initRepo error');
        throw err;
      }
    }
    
    export async function getRepoForceRebase(): Promise<boolean> {
      logger.debug(`getRepoForceRebase()`);
    
      // https://docs.atlassian.com/bitbucket-server/rest/7.0.1/bitbucket-rest.html#idp342
      const res = await api.get(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/settings/pull-requests`
      );
    
      // If the default merge strategy contains `ff-only` the PR can only be merged
      // if it is up to date with the base branch.
      // The current options for id are:
      // no-ff, ff, ff-only, rebase-no-ff, rebase-ff-only, squash, squash-ff-only
      return Boolean(
        res.body.mergeConfig &&
          res.body.mergeConfig.defaultStrategy &&
          res.body.mergeConfig.defaultStrategy.id.indexOf('ff-only') >= 0
      );
    }
    
    export async function setBaseBranch(
      branchName: string = config.defaultBranch
    ): Promise<void> {
      config.baseBranch = branchName;
      await config.storage.setBaseBranch(branchName);
    }
    
    export /* istanbul ignore next */ function setBranchPrefix(
      branchPrefix: string
    ): Promise<void> {
      return config.storage.setBranchPrefix(branchPrefix);
    }
    
    // Search
    
    // Get full file list
    export function getFileList(
      branchName: string = config.baseBranch
    ): Promise<string[]> {
      logger.debug(`getFileList(${branchName})`);
      return config.storage.getFileList(branchName);
    }
    
    // Branch
    
    // Returns true if branch exists, otherwise false
    export function branchExists(branchName: string): Promise<boolean> {
      logger.debug(`branchExists(${branchName})`);
      return config.storage.branchExists(branchName);
    }
    
    export function isBranchStale(branchName: string): Promise<boolean> {
      logger.debug(`isBranchStale(${branchName})`);
      return config.storage.isBranchStale(branchName);
    }
    
    // Gets details for a PR
    export async function getPr(
      prNo: number,
      refreshCache?: boolean
    ): Promise<Pr | null> {
      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}`,
        { useCache: !refreshCache }
      );
    
      const pr: any = {
        displayNumber: `Pull Request #${res.body.id}`,
        ...utils.prInfo(res.body),
        reviewers: res.body.reviewers.map(
          (r: { user: { name: any } }) => r.user.name
        ),
        isModified: false,
      };
    
      pr.version = updatePrVersion(pr.number, pr.version);
    
      if (pr.state === PR_STATE_OPEN) {
        const mergeRes = await api.get(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/merge`,
          { useCache: !refreshCache }
        );
        pr.isConflicted = !!mergeRes.body.conflicted;
        pr.canMerge = !!mergeRes.body.canMerge;
    
        const prCommits = (
          await api.get(
            `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/commits?withCounts=true`,
            { useCache: !refreshCache }
          )
        ).body;
    
        if (prCommits.totalCount === 1) {
          if (global.gitAuthor) {
            const commitAuthorEmail = prCommits.values[0].author.emailAddress;
            if (commitAuthorEmail !== global.gitAuthor.email) {
              logger.debug(
                { prNo },
                'PR is modified: 1 commit but not by configured gitAuthor'
              );
              pr.isModified = true;
            }
          }
        } else {
          logger.debug(
            { prNo },
            `PR is modified: Found ${prCommits.totalCount} commits`
          );
          pr.isModified = true;
        }
      }
    
      if (await branchExists(pr.branchName)) {
        pr.isStale = await isBranchStale(pr.branchName);
      }
    
      return pr;
    }
    
    // TODO: coverage
    // istanbul ignore next
    function matchesState(state: string, desiredState: string): boolean {
      if (desiredState === PR_STATE_ALL) {
        return true;
      }
      if (desiredState.startsWith('!')) {
        return state !== desiredState.substring(1);
      }
      return state === desiredState;
    }
    
    // TODO: coverage
    // istanbul ignore next
    const isRelevantPr = (
      branchName: string,
      prTitle: string | null | undefined,
      state: string
    ) => (p: Pr): boolean =>
      p.branchName === branchName &&
      (!prTitle || p.title === prTitle) &&
      matchesState(p.state, state);
    
    // TODO: coverage
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    export async function getPrList(_args?: any): Promise<Pr[]> {
      logger.debug(`getPrList()`);
      // istanbul ignore next
      if (!config.prList) {
        const query = new URLSearchParams({
          state: 'ALL',
          'role.1': 'AUTHOR',
          'username.1': config.username,
        }).toString();
        const values = await utils.accumulateValues(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests?${query}`
        );
    
        config.prList = values.map(utils.prInfo);
        logger.debug({ length: config.prList.length }, 'Retrieved Pull Requests');
      } else {
        logger.debug('returning cached PR list');
      }
      return config.prList;
    }
    
    // TODO: coverage
    // istanbul ignore next
    export async function findPr({
      branchName,
      prTitle,
      state = PR_STATE_ALL,
      refreshCache,
    }: FindPRConfig): Promise<Pr | null> {
      logger.debug(`findPr(${branchName}, "${prTitle}", "${state}")`);
      const prList = await getPrList({ refreshCache });
      const pr = prList.find(isRelevantPr(branchName, prTitle, state));
      if (pr) {
        logger.debug(`Found PR #${pr.number}`);
      } else {
        logger.debug(`DID NOT Found PR from branch #${branchName}`);
      }
      return pr;
    }
    
    // Returns the Pull Request for a branch. Null if not exists.
    export async function getBranchPr(
      branchName: string,
      refreshCache?: boolean
    ): Promise<Pr | null> {
      logger.debug(`getBranchPr(${branchName})`);
      const existingPr = await findPr({
        branchName,
        state: PR_STATE_OPEN,
      });
      return existingPr ? getPr(existingPr.number, refreshCache) : null;
    }
    
    export function getAllRenovateBranches(
      branchPrefix: string
    ): Promise<string[]> {
      logger.debug('getAllRenovateBranches');
      return config.storage.getAllRenovateBranches(branchPrefix);
    }
    
    export async function commitFilesToBranch({
      branchName,
      files,
      message,
      parentBranch = config.baseBranch,
    }: CommitFilesConfig): Promise<string | null> {
      logger.debug(
        `commitFilesToBranch(${JSON.stringify(
          {
            branchName,
            filesLength: files.length,
            message,
            parentBranch,
          },
          null,
          2
        )})`
      );
      const commit = config.storage.commitFilesToBranch({
        branchName,
        files,
        message,
        parentBranch,
      });
    
      // wait for pr change propagation
      await delay(1000);
      // refresh cache
      await getBranchPr(branchName, true);
      return commit;
    }
    
    export function getFile(filePath: string, branchName: string): Promise<string> {
      logger.debug(`getFile(${filePath}, ${branchName})`);
      return config.storage.getFile(filePath, branchName);
    }
    
    export async function deleteBranch(
      branchName: string,
      closePr = false
    ): Promise<void> {
      logger.debug(`deleteBranch(${branchName}, closePr=${closePr})`);
      // TODO: coverage
      // istanbul ignore next
      if (closePr) {
        // getBranchPr
        const pr = await getBranchPr(branchName);
        if (pr) {
          const { body } = await api.post<{ version: number }>(
            `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${pr.number}/decline?version=${pr.version}`
          );
    
          updatePrVersion(pr.number, body.version);
        }
      }
      return config.storage.deleteBranch(branchName);
    }
    
    export function mergeBranch(branchName: string): Promise<void> {
      logger.debug(`mergeBranch(${branchName})`);
      return config.storage.mergeBranch(branchName);
    }
    
    export function getBranchLastCommitTime(branchName: string): Promise<Date> {
      logger.debug(`getBranchLastCommitTime(${branchName})`);
      return config.storage.getBranchLastCommitTime(branchName);
    }
    
    export /* istanbul ignore next */ function getRepoStatus(): Promise<
      StatusResult
    > {
      return config.storage.getRepoStatus();
    }
    
    async function getStatus(
      branchName: string,
      useCache = true
    ): Promise<utils.BitbucketCommitStatus> {
      const branchCommit = await config.storage.getBranchCommit(branchName);
    
      return (
        await api.get(`./rest/build-status/1.0/commits/stats/${branchCommit}`, {
          useCache,
        })
      ).body;
    }
    
    // Returns the combined status for a branch.
    // umbrella for status checks
    // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-build-rest.html#idp2
    export async function getBranchStatus(
      branchName: string,
      requiredStatusChecks?: string[] | null
    ): Promise<BranchStatus> {
      logger.debug(
        `getBranchStatus(${branchName}, requiredStatusChecks=${!!requiredStatusChecks})`
      );
    
      if (!requiredStatusChecks) {
        // null means disable status checks, so it always succeeds
        logger.debug('Status checks disabled = returning "success"');
        return BranchStatus.green;
      }
    
      if (!(await branchExists(branchName))) {
        throw new Error(REPOSITORY_CHANGED);
      }
    
      try {
        const commitStatus = await getStatus(branchName);
    
        logger.debug({ commitStatus }, 'branch status check result');
    
        if (commitStatus.failed > 0) {
          return BranchStatus.red;
        }
        if (commitStatus.inProgress > 0) {
          return BranchStatus.yellow;
        }
        return commitStatus.successful > 0
          ? BranchStatus.green
          : BranchStatus.yellow;
      } catch (err) {
        logger.warn({ err }, `Failed to get branch status`);
        return BranchStatus.red;
      }
    }
    
    async function getStatusCheck(
      branchName: string,
      useCache = true
    ): Promise<utils.BitbucketStatus[]> {
      const branchCommit = await config.storage.getBranchCommit(branchName);
    
      return utils.accumulateValues(
        `./rest/build-status/1.0/commits/${branchCommit}`,
        'get',
        { useCache }
      );
    }
    
    // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-build-rest.html#idp2
    export async function getBranchStatusCheck(
      branchName: string,
      context: string
    ): Promise<BranchStatus | null> {
      logger.debug(`getBranchStatusCheck(${branchName}, context=${context})`);
    
      try {
        const states = await getStatusCheck(branchName);
    
        for (const state of states) {
          if (state.key === context) {
            switch (state.state) {
              case 'SUCCESSFUL':
                return BranchStatus.green;
              case 'INPROGRESS':
                return BranchStatus.yellow;
              case 'FAILED':
              default:
                return BranchStatus.red;
            }
          }
        }
      } catch (err) {
        logger.warn({ err }, `Failed to check branch status`);
      }
      return null;
    }
    
    export async function setBranchStatus({
      branchName,
      context,
      description,
      state,
      url: targetUrl,
    }: BranchStatusConfig): Promise<void> {
      logger.debug(`setBranchStatus(${branchName})`);
    
      const existingStatus = await getBranchStatusCheck(branchName, context);
      if (existingStatus === state) {
        return;
      }
      logger.debug({ branch: branchName, context, state }, 'Setting branch status');
    
      const branchCommit = await config.storage.getBranchCommit(branchName);
    
      try {
        const body: any = {
          key: context,
          description,
          url: targetUrl || 'https://renovatebot.com',
        };
    
        switch (state) {
          case BranchStatus.green:
            body.state = 'SUCCESSFUL';
            break;
          case BranchStatus.yellow:
            body.state = 'INPROGRESS';
            break;
          case BranchStatus.red:
          default:
            body.state = 'FAILED';
            break;
        }
    
        await api.post(`./rest/build-status/1.0/commits/${branchCommit}`, { body });
    
        // update status cache
        await getStatus(branchName, false);
        await getStatusCheck(branchName, false);
      } catch (err) {
        logger.warn({ err }, `Failed to set branch status`);
      }
    }
    
    // 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 [];
    // }
    
    export /* istanbul ignore next */ function findIssue(
      title: string
    ): Promise<Issue | null> {
      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;
    }
    
    export /* istanbul ignore next */ function ensureIssue({
      title,
    }: EnsureIssueConfig): Promise<EnsureIssueResult | null> {
      logger.warn({ title }, 'Cannot ensure issue');
      // 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;
    }
    
    export /* istanbul ignore next */ function getIssueList(): Promise<Issue[]> {
      logger.debug(`getIssueList()`);
      // TODO: Needs implementation
      return Promise.resolve([]);
    }
    
    export /* istanbul ignore next */ function ensureIssueClosing(
      title: string
    ): Promise<void> {
      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
      return Promise.resolve();
    }
    
    export function addAssignees(iid: number, assignees: string[]): Promise<void> {
      logger.debug(`addAssignees(${iid}, ${assignees})`);
      // 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
      return Promise.resolve();
    }
    
    export async function addReviewers(
      prNo: number,
      reviewers: string[]
    ): Promise<void> {
      logger.debug(`Adding reviewers ${reviewers} to #${prNo}`);
    
      try {
        const pr = await getPr(prNo);
        if (!pr) {
          throw new Error(REPOSITORY_NOT_FOUND);
        }
    
        const reviewersSet = new Set([...pr.reviewers, ...reviewers]);
    
        await api.put(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}`,
          {
            body: {
              title: pr.title,
              version: pr.version,
              reviewers: Array.from(reviewersSet).map((name) => ({
                user: { name },
              })),
            },
          }
        );
        await getPr(prNo, true);
      } catch (err) {
        if (err.statusCode === 404) {
          throw new Error(REPOSITORY_NOT_FOUND);
        } else if (err.statusCode === 409) {
          throw new Error(REPOSITORY_CHANGED);
        } else {
          logger.fatal({ err }, `Failed to add reviewers ${reviewers} to #${prNo}`);
          throw err;
        }
      }
    }
    
    export function deleteLabel(issueNo: number, label: string): Promise<void> {
      logger.debug(`deleteLabel(${issueNo}, ${label})`);
      // TODO: Needs implementation
      // Only used for the "request Renovate to rebase a PR using a label" feature
      return Promise.resolve();
    }
    
    type Comment = { text: string; id: number };
    
    async function getComments(prNo: number): Promise<Comment[]> {
      // GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/activities
      let comments = await utils.accumulateValues(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/activities`
      );
    
      comments = comments
        .filter(
          (a: { action: string; commentAction: string }) =>
            a.action === 'COMMENTED' && a.commentAction === 'ADDED'
        )
        .map((a: { comment: Comment }) => a.comment);
    
      logger.debug(`Found ${comments.length} comments`);
    
      return comments;
    }
    
    async function addComment(prNo: number, text: string): Promise<void> {
      // POST /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments
      await api.post(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments`,
        {
          body: { text },
        }
      );
    }
    
    async function getCommentVersion(
      prNo: number,
      commentId: number
    ): Promise<number> {
      // GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId}
      const { version } = (
        await api.get(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}`
        )
      ).body;
    
      return version;
    }
    
    async function editComment(
      prNo: number,
      commentId: number,
      text: string
    ): Promise<void> {
      const version = await getCommentVersion(prNo, commentId);
    
      // PUT /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId}
      await api.put(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}`,
        {
          body: { text, version },
        }
      );
    }
    
    async function deleteComment(prNo: number, commentId: number): Promise<void> {
      const version = await getCommentVersion(prNo, commentId);
    
      // DELETE /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId}
      await api.delete(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/comments/${commentId}?version=${version}`
      );
    }
    
    export async function ensureComment({
      number,
      topic,
      content,
    }: EnsureCommentConfig): Promise<boolean> {
      const sanitizedContent = sanitize(content);
      try {
        const comments = await getComments(number);
        let body: string;
        let commentId: number | undefined;
        let commentNeedsUpdating: boolean | undefined;
        if (topic) {
          logger.debug(`Ensuring comment "${topic}" in #${number}`);
          body = `### ${topic}\n\n${sanitizedContent}`;
          comments.forEach((comment) => {
            if (comment.text.startsWith(`### ${topic}\n\n`)) {
              commentId = comment.id;
              commentNeedsUpdating = comment.text !== body;
            }
          });
        } else {
          logger.debug(`Ensuring content-only comment in #${number}`);
          body = `${sanitizedContent}`;
          comments.forEach((comment) => {
            if (comment.text === body) {
              commentId = comment.id;
              commentNeedsUpdating = false;
            }
          });
        }
        if (!commentId) {
          await addComment(number, body);
          logger.info(
            { repository: config.repository, prNo: number, topic },
            'Comment added'
          );
        } else if (commentNeedsUpdating) {
          await editComment(number, commentId, body);
          logger.debug(
            { repository: config.repository, prNo: number },
            'Comment updated'
          );
        } else {
          logger.debug('Comment is already update-to-date');
        }
        return true;
      } catch (err) /* istanbul ignore next */ {
        logger.warn({ err }, 'Error ensuring comment');
        return false;
      }
    }
    
    export async function ensureCommentRemoval(
      prNo: number,
      topic: string
    ): Promise<void> {
      try {
        logger.debug(`Ensuring comment "${topic}" in #${prNo} is removed`);
        const comments = await getComments(prNo);
        let commentId: number;
        comments.forEach((comment) => {
          if (comment.text.startsWith(`### ${topic}\n\n`)) {
            commentId = comment.id;
          }
        });
        if (commentId) {
          await deleteComment(prNo, commentId);
        }
      } catch (err) /* istanbul ignore next */ {
        logger.warn({ err }, 'Error ensuring comment removal');
      }
    }
    
    // Pull Request
    
    const escapeHash = (input: string): string =>
      input ? input.replace(/#/g, '%23') : input;
    
    export async function createPr({
      branchName,
      prTitle: title,
      prBody: rawDescription,
      useDefaultBranch,
    }: CreatePRConfig): Promise<Pr> {
      const description = sanitize(rawDescription);
      logger.debug(`createPr(${branchName}, title=${title})`);
      const base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
      let reviewers = [];
    
      /* istanbul ignore else */
      if (config.bbUseDefaultReviewers) {
        logger.debug(`fetching default reviewers`);
        const { id } = (
          await api.get(
            `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}`
          )
        ).body;
    
        const defReviewers = (
          await api.get(
            `./rest/default-reviewers/1.0/projects/${config.projectKey}/repos/${
              config.repositorySlug
            }/reviewers?sourceRefId=refs/heads/${escapeHash(
              branchName
            )}&targetRefId=refs/heads/${base}&sourceRepoId=${id}&targetRepoId=${id}`
          )
        ).body;
    
        reviewers = defReviewers.map((u: { name: string }) => ({
          user: { name: u.name },
        }));
      }
    
      const body = {
        title,
        description,
        fromRef: {
          id: `refs/heads/${branchName}`,
        },
        toRef: {
          id: `refs/heads/${base}`,
        },
        reviewers,
      };
      let prInfoRes: GotResponse;
      try {
        prInfoRes = await api.post(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests`,
          { body }
        );
      } catch (err) /* istanbul ignore next */ {
        if (
          err.body &&
          err.body.errors &&
          err.body.errors.length &&
          err.body.errors[0].exceptionName ===
            'com.atlassian.bitbucket.pull.EmptyPullRequestException'
        ) {
          logger.debug(
            'Empty pull request - deleting branch so it can be recreated next run'
          );
          await deleteBranch(branchName);
          throw new Error(REPOSITORY_CHANGED);
        }
        throw err;
      }
    
      const pr: Pr = {
        id: prInfoRes.body.id,
        displayNumber: `Pull Request #${prInfoRes.body.id}`,
        isModified: false,
        ...utils.prInfo(prInfoRes.body),
      };
    
      updatePrVersion(pr.number, pr.version);
    
      // istanbul ignore if
      if (config.prList) {
        config.prList.push(pr);
      }
    
      return pr;
    }
    
    // Return a list of all modified files in a PR
    // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-rest.html
    export async function getPrFiles(prNo: number): Promise<string[]> {
      logger.debug(`getPrFiles(${prNo})`);
      if (!prNo) {
        return [];
      }
    
      // GET /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/changes
      const values = await utils.accumulateValues<{ path: { toString: string } }>(
        `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/changes?withComments=false`
      );
      return values.map((f) => f.path.toString);
    }
    
    export async function updatePr(
      prNo: number,
      title: string,
      rawDescription: string
    ): Promise<void> {
      const description = sanitize(rawDescription);
      logger.debug(`updatePr(${prNo}, title=${title})`);
    
      try {
        const pr = await getPr(prNo);
        if (!pr) {
          throw Object.assign(new Error(REPOSITORY_NOT_FOUND), { statusCode: 404 });
        }
    
        const { body } = await api.put<{ version: number }>(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}`,
          {
            body: {
              title,
              description,
              version: pr.version,
              reviewers: pr.reviewers.map((name: string) => ({ user: { name } })),
            },
          }
        );
    
        updatePrVersion(prNo, body.version);
      } catch (err) {
        if (err.statusCode === 404) {
          throw new Error(REPOSITORY_NOT_FOUND);
        } else if (err.statusCode === 409) {
          throw new Error(REPOSITORY_CHANGED);
        } else {
          logger.fatal({ err }, `Failed to update PR`);
          throw err;
        }
      }
    }
    
    // https://docs.atlassian.com/bitbucket-server/rest/6.0.0/bitbucket-rest.html#idp261
    export async function mergePr(
      prNo: number,
      branchName: string
    ): Promise<boolean> {
      logger.debug(`mergePr(${prNo}, ${branchName})`);
      // Used for "automerge" feature
      try {
        const pr = await getPr(prNo);
        if (!pr) {
          throw Object.assign(new Error(REPOSITORY_NOT_FOUND), { statusCode: 404 });
        }
        const { body } = await api.post<{ version: number }>(
          `./rest/api/1.0/projects/${config.projectKey}/repos/${config.repositorySlug}/pull-requests/${prNo}/merge?version=${pr.version}`
        );
        updatePrVersion(prNo, body.version);
      } catch (err) {
        if (err.statusCode === 404) {
          throw new Error(REPOSITORY_NOT_FOUND);
        } else if (err.statusCode === 409) {
          logger.warn({ err }, `Failed to merge PR`);
          return false;
        } else {
          logger.warn({ err }, `Failed to merge PR`);
          return false;
        }
      }
    
      logger.debug({ pr: prNo }, 'PR merged');
      // Delete branch
      await deleteBranch(branchName);
      return true;
    }
    
    export function getPrBody(input: string): string {
      logger.debug(`getPrBody(${input.split('\n')[0]})`);
      // Remove any HTML we use
      return smartTruncate(input, 30000)
        .replace(
          'you tick the rebase/retry checkbox',
          'rename PR to start with "rebase!"'
        )
        .replace(/<\/?summary>/g, '**')
        .replace(/<\/?details>/g, '')
        .replace(new RegExp(`\n---\n\n.*?<!-- rebase-check -->.*?(\n|$)`), '')
        .replace(new RegExp('<!--.*?-->', 'g'), '');
    }
    
    export function getCommitMessages(): Promise<string[]> {
      logger.debug(`getCommitMessages()`);
      return config.storage.getCommitMessages();
    }
    
    export function getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> {
      logger.debug(`getVulnerabilityAlerts()`);
      return Promise.resolve([]);
    }