Skip to content
Snippets Groups Projects
Select Git revision
  • b69416ce1745f67c9fc1d149738e2f52feb4f732
  • main default protected
  • renovate/main-zod-3.x
  • renovate/main-ghcr.io-renovatebot-base-image-10.x
  • renovate/main-ghcr.io-containerbase-devcontainer-13.x
  • next
  • revert-31645-feat/rename-gradle-wrapper-validation-action
  • renovate/main-redis-5.x
  • fix/36615b-branch-reuse-no-cache
  • chore/punycode
  • fix/36615-branch-reuse-bug
  • refactor/pin-new-value
  • feat/36219--git-x509-signing
  • feat/structured-logger
  • hotfix/39.264.1
  • feat/skip-dangling
  • gh-readonly-queue/next/pr-36034-7a061c4ca1024a19e2c295d773d9642625d1c2be
  • hotfix/39.238.3
  • refactor/gitlab-auto-approve
  • feat/template-strings
  • gh-readonly-queue/next/pr-35654-137d934242c784e0c45d4b957362214f0eade1d7
  • 41.28.2
  • 41.28.1
  • 41.28.0
  • 41.27.1
  • 41.27.0
  • 41.26.2
  • 41.26.1
  • 41.26.0
  • 41.25.1
  • 41.25.0
  • 41.24.0
  • 41.23.5
  • 41.23.4
  • 41.23.3
  • 41.23.2
  • 41.23.1
  • 41.23.0
  • 41.22.0
  • 41.21.4
  • 41.21.3
41 results

cache.ts

Blame
  • index.ts 26.38 KiB
    import is from '@sindresorhus/is';
    import JSON5 from 'json5';
    import semver from 'semver';
    import { PlatformId } from '../../../constants';
    import {
      REPOSITORY_ACCESS_FORBIDDEN,
      REPOSITORY_ARCHIVED,
      REPOSITORY_BLOCKED,
      REPOSITORY_CHANGED,
      REPOSITORY_EMPTY,
      REPOSITORY_MIRRORED,
    } from '../../../constants/error-messages';
    import { logger } from '../../../logger';
    import { BranchStatus, PrState, VulnerabilityAlert } from '../../../types';
    import * as git from '../../../util/git';
    import { setBaseUrl } from '../../../util/http/gitea';
    import { sanitize } from '../../../util/sanitize';
    import { ensureTrailingSlash } from '../../../util/url';
    import { getPrBodyStruct, hashBody } from '../pr-body';
    import type {
      BranchStatusConfig,
      CreatePRConfig,
      EnsureCommentConfig,
      EnsureCommentRemovalConfig,
      EnsureIssueConfig,
      FindPRConfig,
      Issue,
      MergePRConfig,
      Platform,
      PlatformParams,
      PlatformResult,
      Pr,
      RepoParams,
      RepoResult,
      UpdatePrConfig,
    } from '../types';
    import { smartTruncate } from '../utils/pr-body';
    import * as helper from './gitea-helper';
    import {
      getMergeMethod,
      getRepoUrl,
      smartLinks,
      trimTrailingApiPath,
    } from './utils';
    
    interface GiteaRepoConfig {
      repository: string;
      mergeMethod: helper.PRMergeMethod;
    
      prList: Promise<Pr[]> | null;
      issueList: Promise<Issue[]> | null;
      labelList: Promise<helper.Label[]> | null;
      defaultBranch: string;
      cloneSubmodules: boolean;
    }
    
    const DRAFT_PREFIX = 'WIP: ';
    
    const defaults = {
      hostType: PlatformId.Gitea,
      endpoint: 'https://gitea.com/',
      version: '0.0.0',
    };
    
    let config: GiteaRepoConfig = {} as any;
    let botUserID: number;
    let botUserName: string;
    
    function toRenovateIssue(data: helper.Issue): Issue {
      return {
        number: data.number,
        state: data.state,
        title: data.title,
        body: data.body,
      };
    }
    
    // TODO #7154
    function toRenovatePR(data: helper.PR): Pr | null {
      if (!data) {
        return null;
      }
    
      if (
        !data.base?.ref ||
        !data.head?.label ||
        !data.head?.sha ||
        !data.head?.repo?.full_name
      ) {
        logger.trace(
          `Skipping Pull Request #${data.number} due to missing base and/or head branch`
        );
        return null;
      }
    
      const createdBy = data.user?.username;
      if (createdBy && botUserName && createdBy !== botUserName) {
        return null;
      }
    
      let title = data.title;
      let isDraft = false;
      if (title.startsWith(DRAFT_PREFIX)) {
        title = title.substring(DRAFT_PREFIX.length);
        isDraft = true;
      }
    
      return {
        number: data.number,
        displayNumber: `Pull Request #${data.number}`,
        state: data.state,
        title,
        isDraft,
        bodyStruct: getPrBodyStruct(data.body),
        sha: data.head.sha,
        sourceBranch: data.head.label,
        targetBranch: data.base.ref,
        sourceRepo: data.head.repo.full_name,
        createdAt: data.created_at,
        cannotMergeReason: data.mergeable
          ? undefined
          : `pr.mergeable="${data.mergeable}"`,
        hasAssignees: !!(data.assignee?.login ?? is.nonEmptyArray(data.assignees)),
      };
    }
    
    function matchesState(actual: string, expected: string): boolean {
      if (expected === PrState.All) {
        return true;
      }
      if (expected.startsWith('!')) {
        return actual !== expected.substring(1);
      }
    
      return actual === expected;
    }
    
    function findCommentByTopic(
      comments: helper.Comment[],
      topic: string
    ): helper.Comment | null {
      return comments.find((c) => c.body.startsWith(`### ${topic}\n\n`)) ?? null;
    }
    
    function findCommentByContent(
      comments: helper.Comment[],
      content: string
    ): helper.Comment | null {
      return comments.find((c) => c.body.trim() === content) ?? null;
    }
    
    function getLabelList(): Promise<helper.Label[]> {
      if (config.labelList === null) {
        const repoLabels = helper
          .getRepoLabels(config.repository, {
            useCache: false,
          })
          .then((labels) => {
            logger.debug(`Retrieved ${labels.length} repo labels`);
            return labels;
          });
    
        const orgLabels = helper
          .getOrgLabels(config.repository.split('/')[0], {
            useCache: false,
          })
          .then((labels) => {
            logger.debug(`Retrieved ${labels.length} org labels`);
            return labels;
          })
          .catch((err) => {
            // Will fail if owner of repo is not org or Gitea version < 1.12
            logger.debug(`Unable to fetch organization labels`);
            return [] as helper.Label[];
          });
    
        config.labelList = Promise.all([repoLabels, orgLabels]).then((labels) =>
          ([] as helper.Label[]).concat(...labels)
        );
      }
    
      return config.labelList;
    }
    
    async function lookupLabelByName(name: string): Promise<number | null> {
      logger.debug(`lookupLabelByName(${name})`);
      const labelList = await getLabelList();
      return labelList.find((l) => l.name === name)?.id ?? null;
    }
    
    const platform: Platform = {
      async initPlatform({
        endpoint,
        token,
      }: PlatformParams): Promise<PlatformResult> {
        if (!token) {
          throw new Error('Init: You must configure a Gitea personal access token');
        }
    
        if (endpoint) {
          let baseEndpoint = trimTrailingApiPath(endpoint);
          baseEndpoint = ensureTrailingSlash(baseEndpoint);
          defaults.endpoint = baseEndpoint;
        } else {
          logger.debug('Using default Gitea endpoint: ' + defaults.endpoint);
        }
        setBaseUrl(defaults.endpoint);
    
        let gitAuthor: string;
        try {
          const user = await helper.getCurrentUser({ token });
          gitAuthor = `${user.full_name ?? user.username} <${user.email}>`;
          botUserID = user.id;
          botUserName = user.username;
          defaults.version = await helper.getVersion({ token });
        } catch (err) {
          logger.debug(
            { err },
            'Error authenticating with Gitea. Check your token'
          );
          throw new Error('Init: Authentication failure');
        }
    
        return {
          endpoint: defaults.endpoint,
          gitAuthor,
        };
      },
    
      async getRawFile(
        fileName: string,
        repoName?: string,
        branchOrTag?: string
      ): Promise<string | null> {
        const repo = repoName ?? config.repository;
        const contents = await helper.getRepoContents(repo, fileName, branchOrTag);
        return contents.contentString ?? null;
      },
    
      async getJsonFile(
        fileName: string,
        repoName?: string,
        branchOrTag?: string
      ): Promise<any | null> {
        // TODO #7154
        const raw = (await platform.getRawFile(fileName, repoName, branchOrTag))!;
        return JSON5.parse(raw);
      },
    
      async initRepo({
        repository,
        cloneSubmodules,
        gitUrl,
      }: RepoParams): Promise<RepoResult> {
        let repo: helper.Repo;
    
        config = {} as any;
        config.repository = repository;
        config.cloneSubmodules = !!cloneSubmodules;
    
        // Attempt to fetch information about repository
        try {
          repo = await helper.getRepo(repository);
        } catch (err) {
          logger.debug({ err }, 'Unknown Gitea initRepo error');
          throw err;
        }
    
        // Ensure appropriate repository state and permissions
        if (repo.archived) {
          logger.debug(
            'Repository is archived - throwing error to abort renovation'
          );
          throw new Error(REPOSITORY_ARCHIVED);
        }
        if (repo.mirror) {
          logger.debug(
            'Repository is a mirror - throwing error to abort renovation'
          );
          throw new Error(REPOSITORY_MIRRORED);
        }
        if (!repo.permissions.pull || !repo.permissions.push) {
          logger.debug(
            'Repository does not permit pull and push - throwing error to abort renovation'
          );
          throw new Error(REPOSITORY_ACCESS_FORBIDDEN);
        }
        if (repo.empty) {
          logger.debug('Repository is empty - throwing error to abort renovation');
          throw new Error(REPOSITORY_EMPTY);
        }
    
        if (repo.allow_rebase) {
          config.mergeMethod = 'rebase';
        } else if (repo.allow_rebase_explicit) {
          config.mergeMethod = 'rebase-merge';
        } else if (repo.allow_squash_merge) {
          config.mergeMethod = 'squash';
        } else if (repo.allow_merge_commits) {
          config.mergeMethod = 'merge';
        } else {
          logger.debug(
            'Repository has no allowed merge methods - throwing error to abort renovation'
          );
          throw new Error(REPOSITORY_BLOCKED);
        }
    
        // Determine author email and branches
        config.defaultBranch = repo.default_branch;
        logger.debug(`${repository} default branch = ${config.defaultBranch}`);
    
        const url = getRepoUrl(repo, gitUrl, defaults.endpoint);
    
        // Initialize Git storage
        await git.initRepo({
          ...config,
          url,
        });
    
        // Reset cached resources
        config.prList = null;
        config.issueList = null;
        config.labelList = null;
    
        return {
          defaultBranch: config.defaultBranch,
          isFork: !!repo.fork,
        };
      },
    
      async getRepos(): Promise<string[]> {
        logger.debug('Auto-discovering Gitea repositories');
        try {
          const repos = await helper.searchRepos({
            uid: botUserID,
            archived: false,
          });
          return repos.map((r) => r.full_name);
        } catch (err) {
          logger.error({ err }, 'Gitea getRepos() error');
          throw err;
        }
      },
    
      async setBranchStatus({
        branchName,
        context,
        description,
        state,
        url: target_url,
      }: BranchStatusConfig): Promise<void> {
        try {
          // Create new status for branch commit
          const branchCommit = git.getBranchCommit(branchName);
          // TODO: check branchCommit
    
          await helper.createCommitStatus(config.repository, branchCommit!, {
            state: helper.renovateToGiteaStatusMapping[state] || 'pending',
            context,
            description,
            ...(target_url && { target_url }),
          });
    
          // Refresh caches by re-fetching commit status for branch
          await helper.getCombinedCommitStatus(config.repository, branchName, {
            useCache: false,
          });
        } catch (err) {
          logger.warn({ err }, 'Failed to set branch status');
        }
      },
    
      async getBranchStatus(branchName: string): Promise<BranchStatus> {
        let ccs: helper.CombinedCommitStatus;
        try {
          ccs = await helper.getCombinedCommitStatus(config.repository, branchName);
        } catch (err) {
          if (err.statusCode === 404) {
            logger.debug(
              'Received 404 when checking branch status, assuming branch deletion'
            );
            throw new Error(REPOSITORY_CHANGED);
          }
    
          logger.debug('Unknown error when checking branch status');
          throw err;
        }
    
        logger.debug({ ccs }, 'Branch status check result');
        return (
          helper.giteaToRenovateStatusMapping[ccs.worstStatus] ??
          BranchStatus.yellow
        );
      },
    
      async getBranchStatusCheck(
        branchName: string,
        context: string
      ): Promise<BranchStatus | null> {
        const ccs = await helper.getCombinedCommitStatus(
          config.repository,
          branchName
        );
        const cs = ccs.statuses.find((s) => s.context === context);
        if (!cs) {
          return null;
        } // no status check exists
        const status = helper.giteaToRenovateStatusMapping[cs.status];
        if (status) {
          return status;
        }
        logger.warn(
          { check: cs },
          'Could not map Gitea status value to Renovate status'
        );
        return BranchStatus.yellow;
      },
    
      getPrList(): Promise<Pr[]> {
        if (config.prList === null) {
          config.prList = helper
            .searchPRs(
              config.repository,
              { state: PrState.All },
              { useCache: false }
            )
            .then((prs) => {
              const prList = prs.map(toRenovatePR).filter(is.truthy);
              logger.debug(`Retrieved ${prList.length} Pull Requests`);
              return prList;
            });
        }
    
        return config.prList;
      },
    
      async getPr(number: number): Promise<Pr | null> {
        // Search for pull request in cached list or attempt to query directly
        const prList = await platform.getPrList();
        let pr = prList.find((p) => p.number === number) ?? null;
        if (pr) {
          logger.debug('Returning from cached PRs');
        } else {
          logger.debug('PR not found in cached PRs - trying to fetch directly');
          const gpr = await helper.getPR(config.repository, number);
          pr = toRenovatePR(gpr);
    
          // Add pull request to cache for further lookups / queries
          if (config.prList !== null) {
            // TODO #7154
            (await config.prList).push(pr!);
          }
        }
    
        // Abort and return null if no match was found
        if (!pr) {
          return null;
        }
    
        return pr;
      },
    
      async findPr({
        branchName,
        prTitle: title,
        state = PrState.All,
      }: FindPRConfig): Promise<Pr | null> {
        logger.debug(`findPr(${branchName}, ${title!}, ${state})`);
        const prList = await platform.getPrList();
        const pr = prList.find(
          (p) =>
            p.sourceRepo === config.repository &&
            p.sourceBranch === branchName &&
            matchesState(p.state, state) &&
            (!title || p.title === title)
        );
    
        if (pr) {
          logger.debug(`Found PR #${pr.number}`);
        }
        return pr ?? null;
      },
    
      async createPr({
        sourceBranch,
        targetBranch,
        prTitle,
        prBody: rawBody,
        labels: labelNames,
        platformOptions,
        draftPR,
      }: CreatePRConfig): Promise<Pr> {
        let title = prTitle;
        const base = targetBranch;
        const head = sourceBranch;
        const body = sanitize(rawBody);
        if (draftPR) {
          title = DRAFT_PREFIX + title;
        }
    
        logger.debug(`Creating pull request: ${title} (${head} => ${base})`);
        try {
          const labels = Array.isArray(labelNames)
            ? await Promise.all(labelNames.map(lookupLabelByName))
            : [];
          const gpr = await helper.createPR(config.repository, {
            base,
            head,
            title,
            body,
            labels: labels.filter(is.number),
          });
    
          if (platformOptions?.usePlatformAutomerge) {
            if (semver.gte(defaults.version, '1.17.0')) {
              try {
                await helper.mergePR(config.repository, gpr.number, {
                  // TODO: pass strategy (#16884)
                  Do: config.mergeMethod,
                  merge_when_checks_succeed: true,
                });
    
                logger.debug(
                  { prNumber: gpr.number },
                  'Gitea-native automerge: success'
                );
              } catch (err) {
                logger.warn(
                  { err, prNumber: gpr.number },
                  'Gitea-native automerge: fail'
                );
              }
            } else {
              logger.debug(
                { prNumber: gpr.number },
                'Gitea-native automerge: not supported on this version of Gitea. Use 1.17.0 or newer.'
              );
            }
          }
    
          const pr = toRenovatePR(gpr);
          if (!pr) {
            throw new Error('Can not parse newly created Pull Request');
          }
          if (config.prList !== null) {
            (await config.prList).push(pr);
          }
    
          return pr;
        } catch (err) {
          // When the user manually deletes a branch from Renovate, the PR remains but is no longer linked to any branch. In
          // the most recent versions of Gitea, the PR gets automatically closed when that happens, but older versions do
          // not handle this properly and keep the PR open. As pushing a branch with the same name resurrects the PR, this
          // would cause a HTTP 409 conflict error, which we hereby gracefully handle.
          if (err.statusCode === 409) {
            logger.warn(
              `Attempting to gracefully recover from 409 Conflict response in createPr(${title}, ${sourceBranch})`
            );
    
            // Refresh cached PR list and search for pull request with matching information
            config.prList = null;
            const pr = await platform.findPr({
              branchName: sourceBranch,
              state: PrState.Open,
            });
    
            // If a valid PR was found, return and gracefully recover from the error. Otherwise, abort and throw error.
            if (pr?.bodyStruct) {
              if (pr.title !== title || pr.bodyStruct.hash !== hashBody(body)) {
                logger.debug(
                  `Recovered from 409 Conflict, but PR for ${sourceBranch} is outdated. Updating...`
                );
                await platform.updatePr({
                  number: pr.number,
                  prTitle: title,
                  prBody: body,
                });
                pr.title = title;
                pr.bodyStruct = getPrBodyStruct(body);
              } else {
                logger.debug(
                  `Recovered from 409 Conflict and PR for ${sourceBranch} is up-to-date`
                );
              }
    
              return pr;
            }
          }
    
          throw err;
        }
      },
    
      async updatePr({
        number,
        prTitle,
        prBody: body,
        state,
      }: UpdatePrConfig): Promise<void> {
        let title = prTitle;
        if ((await getPrList()).find((pr) => pr.number === number)?.isDraft) {
          title = DRAFT_PREFIX + title;
        }
    
        await helper.updatePR(config.repository, number, {
          title,
          ...(body && { body }),
          ...(state && { state }),
        });
      },
    
      async mergePr({ id, strategy }: MergePRConfig): Promise<boolean> {
        try {
          await helper.mergePR(config.repository, id, {
            Do: getMergeMethod(strategy) ?? config.mergeMethod,
          });
          return true;
        } catch (err) {
          logger.warn({ err, id }, 'Merging of PR failed');
          return false;
        }
      },
    
      getIssueList(): Promise<Issue[]> {
        if (config.issueList === null) {
          config.issueList = helper
            .searchIssues(config.repository, { state: 'all' }, { useCache: false })
            .then((issues) => {
              const issueList = issues.map(toRenovateIssue);
              logger.debug(`Retrieved ${issueList.length} Issues`);
              return issueList;
            });
        }
    
        return config.issueList;
      },
    
      async getIssue(number: number, useCache = true): Promise<Issue | null> {
        try {
          const body = (
            await helper.getIssue(config.repository, number, {
              useCache,
            })
          ).body;
          return {
            number,
            body,
          };
        } catch (err) /* istanbul ignore next */ {
          logger.debug({ err, number }, 'Error getting issue');
          return null;
        }
      },
    
      async findIssue(title: string): Promise<Issue | null> {
        const issueList = await platform.getIssueList();
        const issue = issueList.find(
          (i) => i.state === 'open' && i.title === title
        );
    
        if (!issue) {
          return null;
        }
        // TODO: types (#7154)
        logger.debug(`Found Issue #${issue.number!}`);
        // TODO #7154
        return getIssue!(issue.number!);
      },
    
      async ensureIssue({
        title,
        reuseTitle,
        body: content,
        labels: labelNames,
        shouldReOpen,
        once,
      }: EnsureIssueConfig): Promise<'updated' | 'created' | null> {
        logger.debug(`ensureIssue(${title})`);
        try {
          const body = smartLinks(content);
    
          const issueList = await platform.getIssueList();
          let issues = issueList.filter((i) => i.title === title);
          if (!issues.length) {
            issues = issueList.filter((i) => i.title === reuseTitle);
          }
    
          const labels = Array.isArray(labelNames)
            ? (await Promise.all(labelNames.map(lookupLabelByName))).filter(
                is.number
              )
            : undefined;
    
          // Update any matching issues which currently exist
          if (issues.length) {
            let activeIssue = issues.find((i) => i.state === 'open');
    
            // If no active issue was found, decide if it shall be skipped, re-opened or updated without state change
            if (!activeIssue) {
              if (once) {
                logger.debug('Issue already closed - skipping update');
                return null;
              }
              if (shouldReOpen) {
                logger.debug('Reopening previously closed Issue');
              }
    
              // Pick the last issue in the list as the active one
              activeIssue = issues[issues.length - 1];
            }
    
            // Close any duplicate issues
            for (const issue of issues) {
              if (issue.state === 'open' && issue.number !== activeIssue.number) {
                // TODO: types (#7154)
                logger.warn(`Closing duplicate Issue #${issue.number!}`);
                // TODO #7154
                await helper.closeIssue(config.repository, issue.number!);
              }
            }
    
            // Check if issue has already correct state
            if (
              activeIssue.title === title &&
              activeIssue.body === body &&
              activeIssue.state === 'open'
            ) {
              logger.debug(
                // TODO: types (#7154)
                `Issue #${activeIssue.number!} is open and up to date - nothing to do`
              );
              return null;
            }
    
            // Update issue body and re-open if enabled
            // TODO: types (#7154)
            logger.debug(`Updating Issue #${activeIssue.number!}`);
            const existingIssue = await helper.updateIssue(
              config.repository,
              // TODO #7154
              activeIssue.number!,
              {
                body,
                title,
                state: shouldReOpen
                  ? 'open'
                  : (activeIssue.state as helper.IssueState),
              }
            );
    
            // Test whether the issues need to be updated
            const existingLabelIds = (existingIssue.labels ?? []).map(
              (label) => label.id
            );
            if (
              labels &&
              (labels.length !== existingLabelIds.length ||
                labels.filter((labelId) => !existingLabelIds.includes(labelId))
                  .length !== 0)
            ) {
              await helper.updateIssueLabels(
                config.repository,
                // TODO #7154
                activeIssue.number!,
                {
                  labels,
                }
              );
            }
    
            return 'updated';
          }
    
          // Create new issue and reset cache
          const issue = await helper.createIssue(config.repository, {
            body,
            title,
            labels,
          });
          logger.debug(`Created new Issue #${issue.number}`);
          config.issueList = null;
    
          return 'created';
        } catch (err) {
          logger.warn({ err }, 'Could not ensure issue');
        }
    
        return null;
      },
    
      async ensureIssueClosing(title: string): Promise<void> {
        logger.debug(`ensureIssueClosing(${title})`);
        const issueList = await platform.getIssueList();
        for (const issue of issueList) {
          if (issue.state === 'open' && issue.title === title) {
            logger.debug({ number: issue.number }, 'Closing issue');
            // TODO #7154
            await helper.closeIssue(config.repository, issue.number!);
          }
        }
      },
    
      async deleteLabel(issue: number, labelName: string): Promise<void> {
        logger.debug(`Deleting label ${labelName} from Issue #${issue}`);
        const label = await lookupLabelByName(labelName);
        if (label) {
          await helper.unassignLabel(config.repository, issue, label);
        } else {
          logger.warn({ issue, labelName }, 'Failed to lookup label for deletion');
        }
      },
    
      getRepoForceRebase(): Promise<boolean> {
        return Promise.resolve(false);
      },
    
      async ensureComment({
        number: issue,
        topic,
        content,
      }: EnsureCommentConfig): Promise<boolean> {
        try {
          let body = sanitize(content);
          const commentList = await helper.getComments(config.repository, issue);
    
          // Search comment by either topic or exact body
          let comment: helper.Comment | null = null;
          if (topic) {
            comment = findCommentByTopic(commentList, topic);
            body = `### ${topic}\n\n${body}`;
          } else {
            comment = findCommentByContent(commentList, body);
          }
    
          // Create a new comment if no match has been found, otherwise update if necessary
          if (!comment) {
            comment = await helper.createComment(config.repository, issue, body);
            logger.info(
              { repository: config.repository, issue, comment: comment.id },
              'Comment added'
            );
          } else if (comment.body === body) {
            logger.debug(`Comment #${comment.id} is already up-to-date`);
          } else {
            await helper.updateComment(config.repository, comment.id, body);
            logger.debug(
              { repository: config.repository, issue, comment: comment.id },
              'Comment updated'
            );
          }
    
          return true;
        } catch (err) {
          logger.warn({ err, issue, subject: topic }, 'Error ensuring comment');
          return false;
        }
      },
    
      async ensureCommentRemoval(
        deleteConfig: EnsureCommentRemovalConfig
      ): Promise<void> {
        const { number: issue } = deleteConfig;
        const key =
          deleteConfig.type === 'by-topic'
            ? deleteConfig.topic
            : deleteConfig.content;
        logger.debug(`Ensuring comment "${key}" in #${issue} is removed`);
        const commentList = await helper.getComments(config.repository, issue);
    
        let comment: helper.Comment | null = null;
        if (deleteConfig.type === 'by-topic') {
          comment = findCommentByTopic(commentList, deleteConfig.topic);
        } else if (deleteConfig.type === 'by-content') {
          const body = sanitize(deleteConfig.content);
          comment = findCommentByContent(commentList, body);
        }
    
        // Abort and do nothing if no matching comment was found
        if (!comment) {
          return;
        }
    
        // Attempt to delete comment
        try {
          await helper.deleteComment(config.repository, comment.id);
        } catch (err) {
          logger.warn(
            { err, issue, config: deleteConfig },
            'Error deleting comment'
          );
        }
      },
    
      async getBranchPr(branchName: string): Promise<Pr | null> {
        logger.debug(`getBranchPr(${branchName})`);
        const pr = await platform.findPr({ branchName, state: PrState.Open });
        return pr ? platform.getPr(pr.number) : null;
      },
    
      async addAssignees(number: number, assignees: string[]): Promise<void> {
        logger.debug(
          `Updating assignees '${assignees?.join(', ')}' on Issue #${number}`
        );
        await helper.updateIssue(config.repository, number, {
          assignees,
        });
      },
    
      async addReviewers(number: number, reviewers: string[]): Promise<void> {
        logger.debug(`Adding reviewers '${reviewers?.join(', ')}' to #${number}`);
        if (semver.lt(defaults.version, '1.14.0')) {
          logger.debug(
            { version: defaults.version },
            'Adding reviewer not yet supported.'
          );
          return;
        }
        try {
          await helper.requestPrReviewers(config.repository, number, { reviewers });
        } catch (err) {
          logger.warn({ err, number, reviewers }, 'Failed to assign reviewer');
        }
      },
    
      massageMarkdown(prBody: string): string {
        return smartTruncate(smartLinks(prBody), 1000000);
      },
    
      getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> {
        return Promise.resolve([]);
      },
    };
    
    // eslint-disable-next-line @typescript-eslint/unbound-method
    export const {
      addAssignees,
      addReviewers,
      createPr,
      deleteLabel,
      ensureComment,
      ensureCommentRemoval,
      ensureIssue,
      ensureIssueClosing,
      findIssue,
      findPr,
      getBranchPr,
      getBranchStatus,
      getBranchStatusCheck,
      getIssue,
      getRawFile,
      getJsonFile,
      getIssueList,
      getPr,
      massageMarkdown,
      getPrList,
      getRepoForceRebase,
      getRepos,
      getVulnerabilityAlerts,
      initPlatform,
      initRepo,
      mergePr,
      setBranchStatus,
      updatePr,
    } = platform;