import URL, { URLSearchParams } from 'url'; import is from '@sindresorhus/is'; import delay from 'delay'; import { configFileNames } from '../../config/app-strings'; import { RenovateConfig } from '../../config/common'; import { PLATFORM_AUTHENTICATION_ERROR, REPOSITORY_ACCESS_FORBIDDEN, REPOSITORY_ARCHIVED, REPOSITORY_CHANGED, REPOSITORY_DISABLED, REPOSITORY_EMPTY, REPOSITORY_MIRRORED, REPOSITORY_NOT_FOUND, } from '../../constants/error-messages'; import { PLATFORM_TYPE_GITLAB } 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 { HttpResponse } from '../../util/http'; import { GitlabHttp, setBaseUrl } from '../../util/http/gitlab'; import { sanitize } from '../../util/sanitize'; import { ensureTrailingSlash } from '../../util/url'; import { BranchStatusConfig, CommitFilesConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfig, EnsureIssueConfig, FindPRConfig, Issue, PlatformConfig, Pr, RepoConfig, RepoParams, VulnerabilityAlert, } from '../common'; import GitStorage, { StatusResult } from '../git/storage'; import { smartTruncate } from '../utils/pr-body'; const gitlabApi = new GitlabHttp(); type MergeMethod = 'merge' | 'rebase_merge' | 'ff'; const defaultConfigFile = configFileNames[0]; let config: { storage: GitStorage; repository: string; localDir: string; defaultBranch: string; baseBranch: string; email: string; prList: any[]; issueList: any[]; optimizeForDisabled: boolean; mergeMethod: MergeMethod; } = {} as any; const defaults = { hostType: PLATFORM_TYPE_GITLAB, endpoint: 'https://gitlab.com/api/v4/', }; let authorId: number; export async function initPlatform({ endpoint, token, }: { token: string; endpoint: string; }): Promise { if (!token) { throw new Error('Init: You must configure a GitLab personal access token'); } if (endpoint) { defaults.endpoint = ensureTrailingSlash(endpoint); setBaseUrl(defaults.endpoint); } else { logger.debug('Using default GitLab endpoint: ' + defaults.endpoint); } let gitAuthor: string; try { const user = ( await gitlabApi.getJson<{ email: string; name: string; id: number }>( `user`, { token } ) ).body; gitAuthor = `${user.name} <${user.email}>`; authorId = user.id; } catch (err) { logger.debug( { err }, 'Error authenticating with GitLab. Check that your token includes "user" permissions' ); throw new Error('Init: Authentication failure'); } const platformConfig: PlatformConfig = { endpoint: defaults.endpoint, gitAuthor, }; return platformConfig; } // Get all repositories that the user has access to export async function getRepos(): Promise { logger.debug('Autodiscovering GitLab repositories'); try { const url = `projects?membership=true&per_page=100&with_merge_requests_enabled=true&min_access_level=30`; const res = await gitlabApi.getJson<{ path_with_namespace: string }[]>( url, { paginate: true } ); logger.debug(`Discovered ${res.body.length} project(s)`); return res.body.map((repo) => repo.path_with_namespace); } catch (err) { logger.error({ err }, `GitLab getRepos error`); throw err; } } function urlEscape(str: string): string { return str ? str.replace(/\//g, '%2F') : str; } export function cleanRepo(): Promise { // istanbul ignore if if (config.storage) { config.storage.cleanRepo(); } // In theory most of this isn't necessary. In practice.. config = {} as any; return Promise.resolve(); } // Initialize GitLab by getting base branch export async function initRepo({ repository, localDir, optimizeForDisabled, }: RepoParams): Promise { config = {} as any; config.repository = urlEscape(repository); config.localDir = localDir; type RepoResponse = { archived: boolean; mirror: boolean; default_branch: string; empty_repo: boolean; http_url_to_repo: string; forked_from_project: boolean; repository_access_level: 'disabled' | 'private' | 'enabled'; merge_requests_access_level: 'disabled' | 'private' | 'enabled'; merge_method: MergeMethod; }; let res: HttpResponse; try { res = await gitlabApi.getJson( `projects/${config.repository}` ); if (res.body.archived) { logger.debug( 'Repository is archived - throwing error to abort renovation' ); throw new Error(REPOSITORY_ARCHIVED); } if (res.body.mirror) { logger.debug( 'Repository is a mirror - throwing error to abort renovation' ); throw new Error(REPOSITORY_MIRRORED); } if (res.body.repository_access_level === 'disabled') { logger.debug( 'Repository portion of project is disabled - throwing error to abort renovation' ); throw new Error(REPOSITORY_DISABLED); } if (res.body.merge_requests_access_level === 'disabled') { logger.debug( 'MRs are disabled for the project - throwing error to abort renovation' ); throw new Error(REPOSITORY_DISABLED); } if (res.body.default_branch === null || res.body.empty_repo) { throw new Error(REPOSITORY_EMPTY); } if (optimizeForDisabled) { let renovateConfig: RenovateConfig; try { renovateConfig = JSON.parse( Buffer.from( ( await gitlabApi.getJson<{ content: string }>( `projects/${config.repository}/repository/files/${defaultConfigFile}?ref=${res.body.default_branch}` ) ).body.content, 'base64' ).toString() ); } catch (err) { // Do nothing } if (renovateConfig && renovateConfig.enabled === false) { throw new Error(REPOSITORY_DISABLED); } } config.defaultBranch = res.body.default_branch; config.baseBranch = config.defaultBranch; config.mergeMethod = res.body.merge_method || 'merge'; logger.debug(`${repository} default branch = ${config.baseBranch}`); // Discover our user email config.email = ( await gitlabApi.getJson<{ email: string }>(`user`) ).body.email; logger.debug('Bot email=' + config.email); delete config.prList; logger.debug('Enabling Git FS'); const opts = hostRules.find({ hostType: defaults.hostType, url: defaults.endpoint, }); let url: string; if ( process.env.GITLAB_IGNORE_REPO_URL || res.body.http_url_to_repo === null ) { logger.debug('no http_url_to_repo found. Falling back to old behaviour.'); const { host, protocol } = URL.parse(defaults.endpoint); url = GitStorage.getUrl({ protocol: protocol.slice(0, -1) as any, auth: 'oauth2:' + opts.token, host, repository, }); } else { logger.debug(`${repository} http URL = ${res.body.http_url_to_repo}`); const repoUrl = URL.parse(`${res.body.http_url_to_repo}`); repoUrl.auth = 'oauth2:' + opts.token; url = URL.format(repoUrl); } config.storage = new GitStorage(); await config.storage.initRepo({ ...config, url, }); } catch (err) /* istanbul ignore next */ { logger.debug({ err }, 'Caught initRepo error'); if (err.message.includes('HEAD is not a symbolic ref')) { throw new Error(REPOSITORY_EMPTY); } if ([REPOSITORY_ARCHIVED, REPOSITORY_EMPTY].includes(err.message)) { throw err; } if (err.statusCode === 403) { throw new Error(REPOSITORY_ACCESS_FORBIDDEN); } if (err.statusCode === 404) { throw new Error(REPOSITORY_NOT_FOUND); } if (err.message === REPOSITORY_DISABLED) { throw err; } logger.debug({ err }, 'Unknown GitLab initRepo error'); throw err; } const repoConfig: RepoConfig = { baseBranch: config.baseBranch, isFork: !!res.body.forked_from_project, }; return repoConfig; } export function getRepoForceRebase(): Promise { return Promise.resolve(config?.mergeMethod !== 'merge'); } export async function setBaseBranch( branchName = config.baseBranch ): Promise { logger.debug(`Setting baseBranch to ${branchName}`); config.baseBranch = branchName; const baseBranchSha = await config.storage.setBaseBranch(branchName); return baseBranchSha; } export /* istanbul ignore next */ function setBranchPrefix( branchPrefix: string ): Promise { return config.storage.setBranchPrefix(branchPrefix); } // Search // Get full file list export function getFileList(): Promise { return config.storage.getFileList(); } // Returns true if branch exists, otherwise false export function branchExists(branchName: string): Promise { return config.storage.branchExists(branchName); } type BranchState = 'pending' | 'running' | 'success' | 'failed' | 'canceled'; interface GitlabBranchStatus { status: BranchState; name: string; allow_failure?: boolean; } async function getStatus( branchName: string, useCache = true ): Promise { const branchSha = await config.storage.getBranchCommit(branchName); const url = `projects/${config.repository}/repository/commits/${branchSha}/statuses`; return ( await gitlabApi.getJson(url, { paginate: true, useCache, }) ).body; } const gitlabToRenovateStatusMapping: Record = { pending: BranchStatus.yellow, created: BranchStatus.yellow, manual: BranchStatus.yellow, running: BranchStatus.yellow, success: BranchStatus.green, failed: BranchStatus.red, canceled: BranchStatus.red, skipped: BranchStatus.red, }; // Returns the combined status for a branch. export async function getBranchStatus( branchName: string, requiredStatusChecks?: string[] | null ): Promise { logger.debug(`getBranchStatus(${branchName})`); if (!requiredStatusChecks) { // null means disable status checks, so it always succeeds return BranchStatus.green; } if (Array.isArray(requiredStatusChecks) && requiredStatusChecks.length) { // This is Unsupported logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`); return BranchStatus.red; } if (!(await branchExists(branchName))) { throw new Error(REPOSITORY_CHANGED); } const res = await getStatus(branchName); logger.debug(`Got res with ${res.length} results`); if (res.length === 0) { // Return 'pending' if we have no status checks return BranchStatus.yellow; } let status: BranchStatus = BranchStatus.green; // default to green res .filter((check) => !check.allow_failure) .forEach((check) => { if (status !== BranchStatus.red) { // if red, stay red let mappedStatus: BranchStatus = gitlabToRenovateStatusMapping[check.status]; if (!mappedStatus) { logger.warn( { check }, 'Could not map GitLab check.status to Renovate status' ); mappedStatus = BranchStatus.yellow; } if (mappedStatus !== BranchStatus.green) { logger.trace({ check }, 'Found non-green check'); status = mappedStatus; } } }); return status; } // Pull Request export async function createPr({ branchName, prTitle: title, prBody: rawDescription, labels, useDefaultBranch, platformOptions, }: CreatePRConfig): Promise { const description = sanitize(rawDescription); const targetBranch = useDefaultBranch ? config.defaultBranch : config.baseBranch; logger.debug(`Creating Merge Request: ${title}`); const res = await gitlabApi.postJson( `projects/${config.repository}/merge_requests`, { body: { source_branch: branchName, target_branch: targetBranch, remove_source_branch: true, title, description, labels: is.array(labels) ? labels.join(',') : null, }, } ); const pr = res.body; pr.number = pr.iid; pr.branchName = branchName; pr.displayNumber = `Merge Request #${pr.iid}`; pr.isModified = false; // istanbul ignore if if (config.prList) { config.prList.push(pr); } if (platformOptions && platformOptions.gitLabAutomerge) { try { const desiredStatus = 'can_be_merged'; const retryTimes = 5; // Check for correct merge request status before setting `merge_when_pipeline_succeeds` to `true`. for (let attempt = 1; attempt <= retryTimes; attempt += 1) { const { body } = await gitlabApi.getJson<{ merge_status: string; pipeline: string; }>(`projects/${config.repository}/merge_requests/${pr.iid}`); // Only continue if the merge request can be merged and has a pipeline. if (body.merge_status === desiredStatus && body.pipeline !== null) { break; } await delay(500 * attempt); } await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${pr.iid}/merge`, { body: { should_remove_source_branch: true, merge_when_pipeline_succeeds: true, }, } ); } catch (err) /* istanbul ignore next */ { logger.debug({ err }, 'Automerge on PR creation failed'); } } return pr; } export async function getPr(iid: number): Promise { logger.debug(`getPr(${iid})`); const url = `projects/${config.repository}/merge_requests/${iid}?include_diverged_commits_count=1`; const pr = ( await gitlabApi.getJson< Pr & { iid: number; source_branch: string; target_branch: string; description: string; diverged_commits_count: number; merge_status: string; } >(url) ).body; // Harmonize fields with GitHub pr.branchName = pr.source_branch; pr.targetBranch = pr.target_branch; pr.number = pr.iid; pr.displayNumber = `Merge Request #${pr.iid}`; pr.body = pr.description; pr.isStale = pr.diverged_commits_count > 0; pr.state = pr.state === 'opened' ? PR_STATE_OPEN : pr.state; pr.isModified = true; if (pr.merge_status === 'cannot_be_merged') { logger.debug('pr cannot be merged'); pr.canMerge = false; pr.isConflicted = true; } else if (pr.state === PR_STATE_OPEN) { const branchStatus = await getBranchStatus(pr.branchName, []); if (branchStatus === BranchStatus.green) { pr.canMerge = true; } } // Check if the most recent branch commit is by us // If not then we don't allow it to be rebased, in case someone's changes would be lost const branchUrl = `projects/${ config.repository }/repository/branches/${urlEscape(pr.source_branch)}`; try { const branch = ( await gitlabApi.getJson<{ commit: { author_email: string } }>(branchUrl) ).body; const branchCommitEmail = branch && branch.commit ? branch.commit.author_email : null; // istanbul ignore if if (branchCommitEmail === config.email) { pr.isModified = false; } else { logger.debug( { branchCommitEmail, configEmail: config.email, iid: pr.iid }, 'Last committer to branch does not match bot email, so PR cannot be rebased.' ); pr.isModified = true; } } catch (err) { logger.debug({ err }, 'Error getting PR branch'); if (pr.state === PR_STATE_OPEN || err.statusCode !== 404) { logger.warn({ err }, 'Error getting PR branch'); pr.isConflicted = true; } } return pr; } // istanbul ignore next async function closePr(iid: number): Promise { await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${iid}`, { body: { state_event: 'close', }, } ); } export async function updatePr( iid: number, title: string, description: string ): Promise { await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${iid}`, { body: { title, description: sanitize(description), }, } ); } export async function mergePr(iid: number): Promise { try { await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${iid}/merge`, { body: { should_remove_source_branch: true, }, } ); return true; } catch (err) /* istanbul ignore next */ { if (err.statusCode === 401) { logger.debug('No permissions to merge PR'); return false; } if (err.statusCode === 406) { logger.debug({ err }, 'PR not acceptable for merging'); return false; } logger.debug({ err }, 'merge PR error'); logger.debug('PR merge failed'); return false; } } export function getPrBody(input: string): string { return smartTruncate( input .replace(/Pull Request/g, 'Merge Request') .replace(/PR/g, 'MR') .replace(/\]\(\.\.\/pull\//g, '](../merge_requests/'), 60000 ); } // Branch // Returns the Pull Request for a branch. Null if not exists. export async function getBranchPr(branchName: string): Promise { logger.debug(`getBranchPr(${branchName})`); // istanbul ignore if if (!(await branchExists(branchName))) { return null; } const query = new URLSearchParams({ per_page: '100', state: 'opened', source_branch: branchName, }).toString(); const urlString = `projects/${config.repository}/merge_requests?${query}`; const res = await gitlabApi.getJson<{ source_branch: string }[]>(urlString, { paginate: true, }); logger.debug(`Got res with ${res.body.length} results`); let pr: any = null; res.body.forEach((result) => { if (result.source_branch === branchName) { pr = result; } }); if (!pr) { return null; } return getPr(pr.iid); } export function getAllRenovateBranches( branchPrefix: string ): Promise { return config.storage.getAllRenovateBranches(branchPrefix); } export function isBranchStale(branchName: string): Promise { return config.storage.isBranchStale(branchName); } export function commitFiles({ branchName, files, message, }: CommitFilesConfig): Promise { return config.storage.commitFiles({ branchName, files, message, }); } export function getFile( filePath: string, branchName?: string ): Promise { return config.storage.getFile(filePath, branchName); } export async function deleteBranch( branchName: string, shouldClosePr = false ): Promise { if (shouldClosePr) { logger.debug('Closing PR'); const pr = await getBranchPr(branchName); // istanbul ignore if if (pr) { await closePr(pr.number); } } return config.storage.deleteBranch(branchName); } export function mergeBranch(branchName: string): Promise { return config.storage.mergeBranch(branchName); } export function getBranchLastCommitTime(branchName: string): Promise { return config.storage.getBranchLastCommitTime(branchName); } // istanbul ignore next export function getRepoStatus(): Promise { return config.storage.getRepoStatus(); } export async function getBranchStatusCheck( branchName: string, context: string ): Promise { // cache-bust in case we have rebased const res = await getStatus(branchName, false); logger.debug(`Got res with ${res.length} results`); for (const check of res) { if (check.name === context) { return gitlabToRenovateStatusMapping[check.status] || BranchStatus.yellow; } } return null; } export async function setBranchStatus({ branchName, context, description, state: renovateState, url: targetUrl, }: BranchStatusConfig): Promise { // First, get the branch commit SHA const branchSha = await config.storage.getBranchCommit(branchName); // Now, check the statuses for that commit const url = `projects/${config.repository}/statuses/${branchSha}`; let state = 'success'; if (renovateState === BranchStatus.yellow) { state = 'pending'; } else if (renovateState === BranchStatus.red) { state = 'failed'; } const options: any = { state, description, context, }; if (targetUrl) { options.target_url = targetUrl; } try { await gitlabApi.postJson(url, { body: options }); // update status cache await getStatus(branchName, false); } catch (err) /* istanbul ignore next */ { if ( err.body && err.body.message && err.body.message.startsWith( 'Cannot transition status via :enqueue from :pending' ) ) { // https://gitlab.com/gitlab-org/gitlab-foss/issues/25807 logger.debug('Ignoring status transition error'); } else { logger.debug({ err }); logger.warn('Failed to set branch status'); } } } // Issue export async function getIssueList(): Promise { if (!config.issueList) { const res = await gitlabApi.getJson<{ iid: number; title: string }[]>( `projects/${config.repository}/issues?state=opened`, { useCache: false, } ); // istanbul ignore if if (!is.array(res.body)) { logger.warn({ responseBody: res.body }, 'Could not retrieve issue list'); return []; } config.issueList = res.body.map((i) => ({ iid: i.iid, title: i.title, })); } return config.issueList; } export async function findIssue(title: string): Promise { logger.debug(`findIssue(${title})`); try { const issueList = await getIssueList(); const issue = issueList.find((i: { title: string }) => i.title === title); if (!issue) { return null; } const issueBody = ( await gitlabApi.getJson<{ description: string }>( `projects/${config.repository}/issues/${issue.iid}` ) ).body.description; return { number: issue.iid, body: issueBody, }; } catch (err) /* istanbul ignore next */ { logger.warn('Error finding issue'); return null; } } export async function ensureIssue({ title, body, }: EnsureIssueConfig): Promise<'updated' | 'created' | null> { logger.debug(`ensureIssue()`); const description = getPrBody(sanitize(body)); try { const issueList = await getIssueList(); const issue = issueList.find((i: { title: string }) => i.title === title); if (issue) { const existingDescription = ( await gitlabApi.getJson<{ description: string }>( `projects/${config.repository}/issues/${issue.iid}` ) ).body.description; if (existingDescription !== description) { logger.debug('Updating issue body'); await gitlabApi.putJson( `projects/${config.repository}/issues/${issue.iid}`, { body: { description }, } ); return 'updated'; } } else { await gitlabApi.postJson(`projects/${config.repository}/issues`, { body: { title, description, }, }); logger.info('Issue created'); // delete issueList so that it will be refetched as necessary delete config.issueList; return 'created'; } } catch (err) /* istanbul ignore next */ { if (err.message.startsWith('Issues are disabled for this repo')) { logger.debug(`Could not create issue: ${err.message}`); } else { logger.warn({ err }, 'Could not ensure issue'); } } return null; } export async function ensureIssueClosing(title: string): Promise { logger.debug(`ensureIssueClosing()`); const issueList = await getIssueList(); for (const issue of issueList) { if (issue.title === title) { logger.debug({ issue }, 'Closing issue'); await gitlabApi.putJson( `projects/${config.repository}/issues/${issue.iid}`, { body: { state_event: 'close' }, } ); } } } export async function addAssignees( iid: number, assignees: string[] ): Promise { logger.debug(`Adding assignees ${assignees} to #${iid}`); try { let assigneeId = ( await gitlabApi.getJson<{ id: number }[]>( `users?username=${assignees[0]}` ) ).body[0].id; let url = `projects/${config.repository}/merge_requests/${iid}?assignee_id=${assigneeId}`; await gitlabApi.putJson(url); try { if (assignees.length > 1) { url = `projects/${config.repository}/merge_requests/${iid}?assignee_ids[]=${assigneeId}`; for (let i = 1; i < assignees.length; i += 1) { assigneeId = ( await gitlabApi.getJson<{ id: number }[]>( `users?username=${assignees[i]}` ) ).body[0].id; url += `&assignee_ids[]=${assigneeId}`; } await gitlabApi.putJson(url); } } catch (error) { logger.error({ iid, assignees }, 'Failed to add multiple assignees'); } } catch (err) { logger.debug({ err }, 'addAssignees error'); logger.warn({ iid, assignees }, 'Failed to add assignees'); } } export function addReviewers(iid: number, reviewers: string[]): Promise { logger.debug(`addReviewers('${iid}, '${reviewers})`); logger.warn('Unimplemented in GitLab: approvals'); return Promise.resolve(); } export async function deleteLabel( issueNo: number, label: string ): Promise { logger.debug(`Deleting label ${label} from #${issueNo}`); try { const pr = await getPr(issueNo); const labels = (pr.labels || []).filter((l: string) => l !== label).join(); await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${issueNo}`, { body: { labels }, } ); } catch (err) /* istanbul ignore next */ { logger.warn({ err, issueNo, label }, 'Failed to delete label'); } } async function getComments(issueNo: number): Promise { // GET projects/:owner/:repo/merge_requests/:number/notes logger.debug(`Getting comments for #${issueNo}`); const url = `projects/${config.repository}/merge_requests/${issueNo}/notes`; const comments = ( await gitlabApi.getJson(url, { paginate: true }) ).body; logger.debug(`Found ${comments.length} comments`); return comments; } async function addComment(issueNo: number, body: string): Promise { // POST projects/:owner/:repo/merge_requests/:number/notes await gitlabApi.postJson( `projects/${config.repository}/merge_requests/${issueNo}/notes`, { body: { body }, } ); } async function editComment( issueNo: number, commentId: number, body: string ): Promise { // PUT projects/:owner/:repo/merge_requests/:number/notes/:id await gitlabApi.putJson( `projects/${config.repository}/merge_requests/${issueNo}/notes/${commentId}`, { body: { body }, } ); } async function deleteComment( issueNo: number, commentId: number ): Promise { // DELETE projects/:owner/:repo/merge_requests/:number/notes/:id await gitlabApi.deleteJson( `projects/${config.repository}/merge_requests/${issueNo}/notes/${commentId}` ); } export async function ensureComment({ number, topic, content, }: EnsureCommentConfig): Promise { const sanitizedContent = sanitize(content); const massagedTopic = topic ? topic.replace(/Pull Request/g, 'Merge Request').replace(/PR/g, 'MR') : topic; const comments = await getComments(number); let body: string; let commentId: number; let commentNeedsUpdating: boolean; if (topic) { logger.debug(`Ensuring comment "${massagedTopic}" in #${number}`); body = `### ${topic}\n\n${sanitizedContent}`; body = body.replace(/Pull Request/g, 'Merge Request').replace(/PR/g, 'MR'); comments.forEach((comment: { body: string; id: number }) => { if (comment.body.startsWith(`### ${massagedTopic}\n\n`)) { commentId = comment.id; commentNeedsUpdating = comment.body !== body; } }); } else { logger.debug(`Ensuring content-only comment in #${number}`); body = `${sanitizedContent}`; comments.forEach((comment: { body: string; id: number }) => { if (comment.body === body) { commentId = comment.id; commentNeedsUpdating = false; } }); } if (!commentId) { await addComment(number, body); logger.debug( { repository: config.repository, issueNo: number }, 'Added comment' ); } else if (commentNeedsUpdating) { await editComment(number, commentId, body); logger.debug( { repository: config.repository, issueNo: number }, 'Updated comment' ); } else { logger.debug('Comment is already update-to-date'); } return true; } type GitlabComment = { body: string; id: number; }; export async function ensureCommentRemoval({ number: issueNo, topic, content, }: EnsureCommentRemovalConfig): Promise { logger.debug( `Ensuring comment "${topic || content}" in #${issueNo} is removed` ); const comments = await getComments(issueNo); let commentId: number | null = null; const byTopic = (comment: GitlabComment): boolean => comment.body.startsWith(`### ${topic}\n\n`); const byContent = (comment: GitlabComment): boolean => comment.body.trim() === content; if (topic) { commentId = comments.find(byTopic)?.id; } else if (content) { commentId = comments.find(byContent)?.id; } if (commentId) { await deleteComment(issueNo, commentId); } } async function fetchPrList(): Promise { const query = new URLSearchParams({ per_page: '100', author_id: `${authorId}`, }).toString(); const urlString = `projects/${config.repository}/merge_requests?${query}`; try { const res = await gitlabApi.getJson< { iid: number; source_branch: string; title: string; state: string; created_at: string; }[] >(urlString, { paginate: true }); return res.body.map((pr) => ({ number: pr.iid, branchName: pr.source_branch, title: pr.title, state: pr.state === 'opened' ? PR_STATE_OPEN : pr.state, createdAt: pr.created_at, })); } catch (err) /* istanbul ignore next */ { logger.debug({ err }, 'Error fetching PR list'); if (err.statusCode === 403) { throw new Error(PLATFORM_AUTHENTICATION_ERROR); } throw err; } } export async function getPrList(): Promise { if (!config.prList) { config.prList = await fetchPrList(); } return config.prList; } /* istanbul ignore next */ export async function getPrFiles(pr: Pr): Promise { return config.storage.getBranchFiles(pr.branchName, pr.targetBranch); } 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; } export async function findPr({ branchName, prTitle, state = PR_STATE_ALL, }: FindPRConfig): Promise { logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`); const prList = await getPrList(); return prList.find( (p: { branchName: string; title: string; state: string }) => p.branchName === branchName && (!prTitle || p.title === prTitle) && matchesState(p.state, state) ); } export function getCommitMessages(): Promise { return config.storage.getCommitMessages(); } export function getVulnerabilityAlerts(): Promise { return Promise.resolve([]); }