mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-15 09:06:25 +00:00
1210 lines
33 KiB
TypeScript
1210 lines
33 KiB
TypeScript
import URL from 'url';
|
|
import is from '@sindresorhus/is';
|
|
import delay from 'delay';
|
|
import JSON5 from 'json5';
|
|
import semver from 'semver';
|
|
import {
|
|
CONFIG_GIT_URL_UNAVAILABLE,
|
|
PLATFORM_AUTHENTICATION_ERROR,
|
|
REPOSITORY_ACCESS_FORBIDDEN,
|
|
REPOSITORY_ARCHIVED,
|
|
REPOSITORY_CHANGED,
|
|
REPOSITORY_DISABLED,
|
|
REPOSITORY_EMPTY,
|
|
REPOSITORY_MIRRORED,
|
|
REPOSITORY_NOT_FOUND,
|
|
TEMPORARY_ERROR,
|
|
} from '../../../constants/error-messages';
|
|
import { logger } from '../../../logger';
|
|
import type { BranchStatus, VulnerabilityAlert } from '../../../types';
|
|
import * as git from '../../../util/git';
|
|
import * as hostRules from '../../../util/host-rules';
|
|
import { setBaseUrl } from '../../../util/http/gitlab';
|
|
import type { HttpResponse } from '../../../util/http/types';
|
|
import * as p from '../../../util/promises';
|
|
import { regEx } from '../../../util/regex';
|
|
import { sanitize } from '../../../util/sanitize';
|
|
import {
|
|
ensureTrailingSlash,
|
|
getQueryString,
|
|
parseUrl,
|
|
} from '../../../util/url';
|
|
import { getPrBodyStruct } from '../pr-body';
|
|
import type {
|
|
BranchStatusConfig,
|
|
CreatePRConfig,
|
|
EnsureCommentConfig,
|
|
EnsureCommentRemovalConfig,
|
|
EnsureIssueConfig,
|
|
FindPRConfig,
|
|
GitUrlOption,
|
|
Issue,
|
|
MergePRConfig,
|
|
PlatformParams,
|
|
PlatformPrOptions,
|
|
PlatformResult,
|
|
Pr,
|
|
RepoParams,
|
|
RepoResult,
|
|
UpdatePrConfig,
|
|
} from '../types';
|
|
import { repoFingerprint } from '../util';
|
|
import { smartTruncate } from '../utils/pr-body';
|
|
import { getMemberUserIDs, getUserID, gitlabApi, isUserBusy } from './http';
|
|
import { getMR, updateMR } from './merge-request';
|
|
import type {
|
|
GitLabMergeRequest,
|
|
GitlabComment,
|
|
GitlabIssue,
|
|
GitlabPr,
|
|
MergeMethod,
|
|
RepoResponse,
|
|
} from './types';
|
|
|
|
let config: {
|
|
repository: string;
|
|
email: string;
|
|
prList: any[] | undefined;
|
|
issueList: GitlabIssue[] | undefined;
|
|
mergeMethod: MergeMethod;
|
|
defaultBranch: string;
|
|
cloneSubmodules: boolean | undefined;
|
|
ignorePrAuthor: boolean | undefined;
|
|
squash: boolean;
|
|
} = {} as any;
|
|
|
|
const defaults = {
|
|
hostType: 'gitlab',
|
|
endpoint: 'https://gitlab.com/api/v4/',
|
|
version: '0.0.0',
|
|
};
|
|
|
|
const DRAFT_PREFIX = 'Draft: ';
|
|
const DRAFT_PREFIX_DEPRECATED = 'WIP: ';
|
|
|
|
let draftPrefix = DRAFT_PREFIX;
|
|
|
|
export async function initPlatform({
|
|
endpoint,
|
|
token,
|
|
gitAuthor,
|
|
}: PlatformParams): Promise<PlatformResult> {
|
|
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);
|
|
}
|
|
const platformConfig: PlatformResult = {
|
|
endpoint: defaults.endpoint,
|
|
};
|
|
let gitlabVersion: string;
|
|
try {
|
|
if (!gitAuthor) {
|
|
const user = (
|
|
await gitlabApi.getJson<{ email: string; name: string; id: number }>(
|
|
`user`,
|
|
{ token }
|
|
)
|
|
).body;
|
|
platformConfig.gitAuthor = `${user.name} <${user.email}>`;
|
|
}
|
|
// istanbul ignore if: experimental feature
|
|
if (process.env.RENOVATE_X_PLATFORM_VERSION) {
|
|
gitlabVersion = process.env.RENOVATE_X_PLATFORM_VERSION;
|
|
} else {
|
|
const version = (
|
|
await gitlabApi.getJson<{ version: string }>('version', { token })
|
|
).body;
|
|
gitlabVersion = version.version;
|
|
}
|
|
logger.debug('GitLab version is: ' + gitlabVersion);
|
|
// version is 'x.y.z-edition', so not strictly semver; need to strip edition
|
|
[gitlabVersion] = gitlabVersion.split('-');
|
|
defaults.version = gitlabVersion;
|
|
} catch (err) {
|
|
logger.debug(
|
|
{ err },
|
|
'Error authenticating with GitLab. Check that your token includes "api" permissions'
|
|
);
|
|
throw new Error('Init: Authentication failure');
|
|
}
|
|
draftPrefix = semver.lt(defaults.version, '13.2.0')
|
|
? DRAFT_PREFIX_DEPRECATED
|
|
: DRAFT_PREFIX;
|
|
|
|
return platformConfig;
|
|
}
|
|
|
|
// Get all repositories that the user has access to
|
|
export async function getRepos(): Promise<string[]> {
|
|
logger.debug('Autodiscovering GitLab repositories');
|
|
try {
|
|
const url = `projects?membership=true&per_page=100&with_merge_requests_enabled=true&min_access_level=30&archived=false`;
|
|
const res = await gitlabApi.getJson<RepoResponse[]>(url, {
|
|
paginate: true,
|
|
});
|
|
logger.debug(`Discovered ${res.body.length} project(s)`);
|
|
return res.body
|
|
.filter((repo) => !repo.mirror)
|
|
.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(regEx(/\//g), '%2F') : str;
|
|
}
|
|
|
|
export async function getRawFile(
|
|
fileName: string,
|
|
repoName?: string,
|
|
branchOrTag?: string
|
|
): Promise<string | null> {
|
|
const escapedFileName = urlEscape(fileName);
|
|
const repo = urlEscape(repoName ?? config.repository);
|
|
const url =
|
|
`projects/${repo}/repository/files/${escapedFileName}?ref=` +
|
|
(branchOrTag ?? `HEAD`);
|
|
const res = await gitlabApi.getJson<{ content: string }>(url);
|
|
const buf = res.body.content;
|
|
const str = Buffer.from(buf, 'base64').toString();
|
|
return str;
|
|
}
|
|
|
|
export async function getJsonFile(
|
|
fileName: string,
|
|
repoName?: string,
|
|
branchOrTag?: string
|
|
): Promise<any | null> {
|
|
// TODO #7154
|
|
const raw = (await getRawFile(fileName, repoName, branchOrTag)) as string;
|
|
return JSON5.parse(raw);
|
|
}
|
|
|
|
function getRepoUrl(
|
|
repository: string,
|
|
gitUrl: GitUrlOption | undefined,
|
|
res: HttpResponse<RepoResponse>
|
|
): string {
|
|
if (gitUrl === 'ssh') {
|
|
if (!res.body.ssh_url_to_repo) {
|
|
throw new Error(CONFIG_GIT_URL_UNAVAILABLE);
|
|
}
|
|
logger.debug(`Using ssh URL: ${res.body.ssh_url_to_repo}`);
|
|
return res.body.ssh_url_to_repo;
|
|
}
|
|
|
|
const opts = hostRules.find({
|
|
hostType: defaults.hostType,
|
|
url: defaults.endpoint,
|
|
});
|
|
|
|
if (
|
|
gitUrl === 'endpoint' ||
|
|
process.env.GITLAB_IGNORE_REPO_URL ||
|
|
res.body.http_url_to_repo === null
|
|
) {
|
|
if (res.body.http_url_to_repo === null) {
|
|
logger.debug('no http_url_to_repo found. Falling back to old behaviour.');
|
|
}
|
|
if (process.env.GITLAB_IGNORE_REPO_URL) {
|
|
logger.warn(
|
|
'GITLAB_IGNORE_REPO_URL environment variable is deprecated. Please use "gitUrl" option.'
|
|
);
|
|
}
|
|
|
|
// TODO: null check (#7154)
|
|
const { protocol, host, pathname } = parseUrl(defaults.endpoint)!;
|
|
const newPathname = pathname.slice(0, pathname.indexOf('/api'));
|
|
const url = URL.format({
|
|
protocol: protocol.slice(0, -1) || 'https',
|
|
// TODO: types (#7154)
|
|
auth: `oauth2:${opts.token!}`,
|
|
host,
|
|
pathname: newPathname + '/' + repository + '.git',
|
|
});
|
|
logger.debug(`Using URL based on configured endpoint, url:${url}`);
|
|
return url;
|
|
}
|
|
|
|
logger.debug(`Using http URL: ${res.body.http_url_to_repo}`);
|
|
const repoUrl = URL.parse(`${res.body.http_url_to_repo}`);
|
|
// TODO: types (#7154)
|
|
repoUrl.auth = `oauth2:${opts.token!}`;
|
|
return URL.format(repoUrl);
|
|
}
|
|
|
|
// Initialize GitLab by getting base branch
|
|
export async function initRepo({
|
|
repository,
|
|
cloneSubmodules,
|
|
ignorePrAuthor,
|
|
gitUrl,
|
|
endpoint,
|
|
}: RepoParams): Promise<RepoResult> {
|
|
config = {} as any;
|
|
config.repository = urlEscape(repository);
|
|
config.cloneSubmodules = cloneSubmodules;
|
|
config.ignorePrAuthor = ignorePrAuthor;
|
|
|
|
let res: HttpResponse<RepoResponse>;
|
|
try {
|
|
res = await gitlabApi.getJson<RepoResponse>(
|
|
`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);
|
|
}
|
|
config.defaultBranch = res.body.default_branch;
|
|
// istanbul ignore if
|
|
if (!config.defaultBranch) {
|
|
logger.warn({ resBody: res.body }, 'Error fetching GitLab project');
|
|
throw new Error(TEMPORARY_ERROR);
|
|
}
|
|
config.mergeMethod = res.body.merge_method || 'merge';
|
|
if (res.body.squash_option) {
|
|
config.squash =
|
|
res.body.squash_option === 'always' ||
|
|
res.body.squash_option === 'default_on';
|
|
}
|
|
logger.debug(`${repository} default branch = ${config.defaultBranch}`);
|
|
delete config.prList;
|
|
logger.debug('Enabling Git FS');
|
|
const url = getRepoUrl(repository, gitUrl, res);
|
|
await git.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: RepoResult = {
|
|
defaultBranch: config.defaultBranch,
|
|
isFork: !!res.body.forked_from_project,
|
|
repoFingerprint: repoFingerprint(res.body.id, defaults.endpoint),
|
|
};
|
|
return repoConfig;
|
|
}
|
|
|
|
export function getRepoForceRebase(): Promise<boolean> {
|
|
return Promise.resolve(config?.mergeMethod !== 'merge');
|
|
}
|
|
|
|
type BranchState =
|
|
| 'pending'
|
|
| 'created'
|
|
| 'running'
|
|
| 'waiting_for_resource'
|
|
| 'manual'
|
|
| 'success'
|
|
| 'failed'
|
|
| 'canceled'
|
|
| 'skipped'
|
|
| 'scheduled';
|
|
|
|
interface GitlabBranchStatus {
|
|
status: BranchState;
|
|
name: string;
|
|
allow_failure?: boolean;
|
|
}
|
|
|
|
async function getStatus(
|
|
branchName: string,
|
|
useCache = true
|
|
): Promise<GitlabBranchStatus[]> {
|
|
const branchSha = git.getBranchCommit(branchName);
|
|
try {
|
|
// TODO: types (#7154)
|
|
const url = `projects/${
|
|
config.repository
|
|
}/repository/commits/${branchSha!}/statuses`;
|
|
|
|
return (
|
|
await gitlabApi.getJson<GitlabBranchStatus[]>(url, {
|
|
paginate: true,
|
|
useCache,
|
|
})
|
|
).body;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.debug({ err }, 'Error getting commit status');
|
|
if (err.response?.statusCode === 404) {
|
|
throw new Error(REPOSITORY_CHANGED);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const gitlabToRenovateStatusMapping: Record<BranchState, BranchStatus> = {
|
|
pending: 'yellow',
|
|
created: 'yellow',
|
|
manual: 'yellow',
|
|
running: 'yellow',
|
|
waiting_for_resource: 'yellow',
|
|
success: 'green',
|
|
failed: 'red',
|
|
canceled: 'red',
|
|
skipped: 'red',
|
|
scheduled: 'yellow',
|
|
};
|
|
|
|
// Returns the combined status for a branch.
|
|
export async function getBranchStatus(
|
|
branchName: string
|
|
): Promise<BranchStatus> {
|
|
logger.debug(`getBranchStatus(${branchName})`);
|
|
|
|
if (!git.branchExists(branchName)) {
|
|
throw new Error(REPOSITORY_CHANGED);
|
|
}
|
|
|
|
const branchStatuses = await getStatus(branchName);
|
|
// istanbul ignore if
|
|
if (!is.array(branchStatuses)) {
|
|
logger.warn(
|
|
{ branchName, branchStatuses },
|
|
'Empty or unexpected branch statuses'
|
|
);
|
|
return 'yellow';
|
|
}
|
|
logger.debug(`Got res with ${branchStatuses.length} results`);
|
|
|
|
const mrStatus = (await getBranchPr(branchName))?.headPipelineStatus;
|
|
if (!is.undefined(mrStatus)) {
|
|
branchStatuses.push({
|
|
status: mrStatus as BranchState,
|
|
name: 'head_pipeline',
|
|
});
|
|
}
|
|
// ignore all skipped jobs
|
|
const res = branchStatuses.filter((check) => check.status !== 'skipped');
|
|
if (res.length === 0) {
|
|
// Return 'pending' if we have no status checks
|
|
return 'yellow';
|
|
}
|
|
let status: BranchStatus = 'green'; // default to green
|
|
res
|
|
.filter((check) => !check.allow_failure)
|
|
.forEach((check) => {
|
|
if (status !== '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 = 'yellow';
|
|
}
|
|
if (mappedStatus !== 'green') {
|
|
logger.trace({ check }, 'Found non-green check');
|
|
status = mappedStatus;
|
|
}
|
|
}
|
|
});
|
|
return status;
|
|
}
|
|
|
|
// Pull Request
|
|
|
|
function massagePr(prToModify: Pr): Pr {
|
|
const pr = prToModify;
|
|
if (pr.title.startsWith(DRAFT_PREFIX)) {
|
|
pr.title = pr.title.substring(DRAFT_PREFIX.length);
|
|
pr.isDraft = true;
|
|
} else if (pr.title.startsWith(DRAFT_PREFIX_DEPRECATED)) {
|
|
pr.title = pr.title.substring(DRAFT_PREFIX_DEPRECATED.length);
|
|
pr.isDraft = true;
|
|
}
|
|
return pr;
|
|
}
|
|
|
|
async function fetchPrList(): Promise<Pr[]> {
|
|
const searchParams = {
|
|
per_page: '100',
|
|
} as any;
|
|
// istanbul ignore if
|
|
if (!config.ignorePrAuthor) {
|
|
searchParams.scope = 'created_by_me';
|
|
}
|
|
const query = getQueryString(searchParams);
|
|
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) =>
|
|
massagePr({
|
|
number: pr.iid,
|
|
sourceBranch: pr.source_branch,
|
|
title: pr.title,
|
|
state: pr.state === 'opened' ? '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<Pr[]> {
|
|
if (!config.prList) {
|
|
config.prList = await fetchPrList();
|
|
}
|
|
return config.prList;
|
|
}
|
|
|
|
async function ignoreApprovals(pr: number): Promise<void> {
|
|
try {
|
|
const url = `projects/${config.repository}/merge_requests/${pr}/approval_rules`;
|
|
const { body: rules } = await gitlabApi.getJson<{ name: string }[]>(url);
|
|
const ruleName = 'renovateIgnoreApprovals';
|
|
const zeroApproversRule = rules?.find(({ name }) => name === ruleName);
|
|
if (!zeroApproversRule) {
|
|
await gitlabApi.postJson(url, {
|
|
body: {
|
|
name: ruleName,
|
|
approvals_required: 0,
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err }, 'GitLab: Error adding approval rule');
|
|
}
|
|
}
|
|
|
|
async function tryPrAutomerge(
|
|
pr: number,
|
|
platformOptions: PlatformPrOptions | undefined
|
|
): Promise<void> {
|
|
if (platformOptions?.usePlatformAutomerge) {
|
|
try {
|
|
if (platformOptions?.gitLabIgnoreApprovals) {
|
|
await ignoreApprovals(pr);
|
|
}
|
|
|
|
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}`);
|
|
// 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}/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');
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function createPr({
|
|
sourceBranch,
|
|
targetBranch,
|
|
prTitle,
|
|
prBody: rawDescription,
|
|
draftPR,
|
|
labels,
|
|
platformOptions,
|
|
}: CreatePRConfig): Promise<Pr> {
|
|
let title = prTitle;
|
|
if (draftPR) {
|
|
title = draftPrefix + title;
|
|
}
|
|
const description = sanitize(rawDescription);
|
|
logger.debug(`Creating Merge Request: ${title}`);
|
|
const res = await gitlabApi.postJson<Pr & { iid: number }>(
|
|
`projects/${config.repository}/merge_requests`,
|
|
{
|
|
body: {
|
|
source_branch: sourceBranch,
|
|
target_branch: targetBranch,
|
|
remove_source_branch: true,
|
|
title,
|
|
description,
|
|
labels: (labels ?? []).join(','),
|
|
squash: config.squash,
|
|
},
|
|
}
|
|
);
|
|
const pr = res.body;
|
|
pr.number = pr.iid;
|
|
pr.sourceBranch = sourceBranch;
|
|
// istanbul ignore if
|
|
if (config.prList) {
|
|
config.prList.push(pr);
|
|
}
|
|
|
|
await tryPrAutomerge(pr.iid, platformOptions);
|
|
|
|
return massagePr(pr);
|
|
}
|
|
|
|
export async function getPr(iid: number): Promise<GitlabPr> {
|
|
logger.debug(`getPr(${iid})`);
|
|
const mr = await getMR(config.repository, iid);
|
|
|
|
// Harmonize fields with GitHub
|
|
const pr: GitlabPr = {
|
|
sourceBranch: mr.source_branch,
|
|
targetBranch: mr.target_branch,
|
|
number: mr.iid,
|
|
bodyStruct: getPrBodyStruct(mr.description),
|
|
state: mr.state === 'opened' ? 'open' : mr.state,
|
|
headPipelineStatus: mr.head_pipeline?.status,
|
|
hasAssignees: !!(mr.assignee?.id ?? mr.assignees?.[0]?.id),
|
|
reviewers: mr.reviewers?.map(({ username }) => username),
|
|
title: mr.title,
|
|
labels: mr.labels,
|
|
sha: mr.sha,
|
|
};
|
|
|
|
return massagePr(pr);
|
|
}
|
|
|
|
export async function updatePr({
|
|
number: iid,
|
|
prTitle,
|
|
prBody: description,
|
|
state,
|
|
platformOptions,
|
|
}: UpdatePrConfig): Promise<void> {
|
|
let title = prTitle;
|
|
if ((await getPrList()).find((pr) => pr.number === iid)?.isDraft) {
|
|
title = draftPrefix + title;
|
|
}
|
|
const newState = {
|
|
['closed']: 'close',
|
|
['open']: 'reopen',
|
|
// TODO: null check (#7154)
|
|
}[state!];
|
|
await gitlabApi.putJson(
|
|
`projects/${config.repository}/merge_requests/${iid}`,
|
|
{
|
|
body: {
|
|
title,
|
|
description: sanitize(description),
|
|
...(newState && { state_event: newState }),
|
|
},
|
|
}
|
|
);
|
|
|
|
await tryPrAutomerge(iid, platformOptions);
|
|
}
|
|
|
|
export async function mergePr({ id }: MergePRConfig): Promise<boolean> {
|
|
try {
|
|
await gitlabApi.putJson(
|
|
`projects/${config.repository}/merge_requests/${id}/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 massageMarkdown(input: string): string {
|
|
let desc = input
|
|
.replace(regEx(/Pull Request/g), 'Merge Request')
|
|
.replace(regEx(/PR/g), 'MR')
|
|
.replace(regEx(/\]\(\.\.\/pull\//g), '](!')
|
|
// Strip unicode null characters as GitLab markdown does not permit them
|
|
.replace(regEx(/\u0000/g), ''); // eslint-disable-line no-control-regex
|
|
|
|
if (semver.lt(defaults.version, '13.4.0')) {
|
|
logger.debug(
|
|
{ version: defaults.version },
|
|
'GitLab versions earlier than 13.4 have issues with long descriptions, truncating to 25K characters'
|
|
);
|
|
|
|
desc = smartTruncate(desc, 25000);
|
|
} else {
|
|
desc = smartTruncate(desc, 1000000);
|
|
}
|
|
|
|
return desc;
|
|
}
|
|
|
|
// Branch
|
|
|
|
function matchesState(state: string, desiredState: string): boolean {
|
|
if (desiredState === 'all') {
|
|
return true;
|
|
}
|
|
if (desiredState.startsWith('!')) {
|
|
return state !== desiredState.substring(1);
|
|
}
|
|
return state === desiredState;
|
|
}
|
|
|
|
export async function findPr({
|
|
branchName,
|
|
prTitle,
|
|
state = 'all',
|
|
}: FindPRConfig): Promise<Pr | null> {
|
|
logger.debug(`findPr(${branchName}, ${prTitle!}, ${state})`);
|
|
const prList = await getPrList();
|
|
return (
|
|
prList.find(
|
|
(p: { sourceBranch: string; title: string; state: string }) =>
|
|
p.sourceBranch === branchName &&
|
|
(!prTitle || p.title === prTitle) &&
|
|
matchesState(p.state, state)
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
// Returns the Pull Request for a branch. Null if not exists.
|
|
export async function getBranchPr(
|
|
branchName: string
|
|
): Promise<GitlabPr | null> {
|
|
logger.debug(`getBranchPr(${branchName})`);
|
|
const existingPr = await findPr({
|
|
branchName,
|
|
state: 'open',
|
|
});
|
|
return existingPr ? getPr(existingPr.number) : null;
|
|
}
|
|
|
|
export async function getBranchStatusCheck(
|
|
branchName: string,
|
|
context: string
|
|
): Promise<BranchStatus | null> {
|
|
// 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] || 'yellow';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function setBranchStatus({
|
|
branchName,
|
|
context,
|
|
description,
|
|
state: renovateState,
|
|
url: targetUrl,
|
|
}: BranchStatusConfig): Promise<void> {
|
|
// First, get the branch commit SHA
|
|
const branchSha = git.getBranchCommit(branchName);
|
|
// Now, check the statuses for that commit
|
|
// TODO: types (#7154)
|
|
const url = `projects/${config.repository}/statuses/${branchSha!}`;
|
|
let state = 'success';
|
|
if (renovateState === 'yellow') {
|
|
state = 'pending';
|
|
} else if (renovateState === 'red') {
|
|
state = 'failed';
|
|
}
|
|
const options: any = {
|
|
state,
|
|
description,
|
|
context,
|
|
};
|
|
if (targetUrl) {
|
|
options.target_url = targetUrl;
|
|
}
|
|
try {
|
|
// give gitlab some time to create pipelines for the sha
|
|
await delay(1000);
|
|
|
|
await gitlabApi.postJson(url, { body: options });
|
|
|
|
// update status cache
|
|
await getStatus(branchName, false);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (
|
|
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<GitlabIssue[]> {
|
|
if (!config.issueList) {
|
|
const query = getQueryString({
|
|
per_page: '100',
|
|
scope: 'created_by_me',
|
|
state: 'opened',
|
|
});
|
|
const res = await gitlabApi.getJson<
|
|
{ iid: number; title: string; labels: string[] }[]
|
|
>(`projects/${config.repository}/issues?${query}`, {
|
|
useCache: false,
|
|
paginate: true,
|
|
});
|
|
// 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,
|
|
labels: i.labels,
|
|
}));
|
|
}
|
|
return config.issueList;
|
|
}
|
|
|
|
export async function getIssue(
|
|
number: number,
|
|
useCache = true
|
|
): Promise<Issue | null> {
|
|
try {
|
|
const issueBody = (
|
|
await gitlabApi.getJson<{ description: string }>(
|
|
`projects/${config.repository}/issues/${number}`,
|
|
{ useCache }
|
|
)
|
|
).body.description;
|
|
return {
|
|
number,
|
|
body: issueBody,
|
|
};
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.debug({ err, number }, 'Error getting issue');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function findIssue(title: string): Promise<Issue | null> {
|
|
logger.debug(`findIssue(${title})`);
|
|
try {
|
|
const issueList = await getIssueList();
|
|
const issue = issueList.find((i) => i.title === title);
|
|
if (!issue) {
|
|
return null;
|
|
}
|
|
return await getIssue(issue.iid);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.warn('Error finding issue');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function ensureIssue({
|
|
title,
|
|
reuseTitle,
|
|
body,
|
|
labels,
|
|
confidential,
|
|
}: EnsureIssueConfig): Promise<'updated' | 'created' | null> {
|
|
logger.debug(`ensureIssue()`);
|
|
const description = massageMarkdown(sanitize(body));
|
|
try {
|
|
const issueList = await getIssueList();
|
|
let issue = issueList.find((i) => i.title === title);
|
|
if (!issue) {
|
|
issue = issueList.find((i) => i.title === reuseTitle);
|
|
}
|
|
if (issue) {
|
|
const existingDescription = (
|
|
await gitlabApi.getJson<{ description: string }>(
|
|
`projects/${config.repository}/issues/${issue.iid}`
|
|
)
|
|
).body.description;
|
|
if (issue.title !== title || existingDescription !== description) {
|
|
logger.debug('Updating issue');
|
|
await gitlabApi.putJson(
|
|
`projects/${config.repository}/issues/${issue.iid}`,
|
|
{
|
|
body: {
|
|
title,
|
|
description,
|
|
labels: (labels ?? issue.labels ?? []).join(','),
|
|
confidential: confidential ?? false,
|
|
},
|
|
}
|
|
);
|
|
return 'updated';
|
|
}
|
|
} else {
|
|
await gitlabApi.postJson(`projects/${config.repository}/issues`, {
|
|
body: {
|
|
title,
|
|
description,
|
|
labels: (labels ?? []).join(','),
|
|
confidential: confidential ?? false,
|
|
},
|
|
});
|
|
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 as Error).message}`);
|
|
} else {
|
|
logger.warn({ err }, 'Could not ensure issue');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function ensureIssueClosing(title: string): Promise<void> {
|
|
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<void> {
|
|
try {
|
|
logger.debug(`Adding assignees '${assignees.join(', ')}' to #${iid}`);
|
|
const assigneeIds: number[] = [];
|
|
for (const assignee of assignees) {
|
|
assigneeIds.push(await getUserID(assignee));
|
|
}
|
|
const url = `projects/${
|
|
config.repository
|
|
}/merge_requests/${iid}?${getQueryString({
|
|
'assignee_ids[]': assigneeIds,
|
|
})}`;
|
|
await gitlabApi.putJson(url);
|
|
} catch (err) {
|
|
logger.debug({ err }, 'addAssignees error');
|
|
logger.warn({ iid, assignees }, 'Failed to add assignees');
|
|
}
|
|
}
|
|
|
|
export async function addReviewers(
|
|
iid: number,
|
|
reviewers: string[]
|
|
): Promise<void> {
|
|
logger.debug(`Adding reviewers '${reviewers.join(', ')}' to #${iid}`);
|
|
|
|
if (semver.lt(defaults.version, '13.9.0')) {
|
|
logger.warn(
|
|
{ version: defaults.version },
|
|
'Adding reviewers is only available in GitLab 13.9 and onwards'
|
|
);
|
|
return;
|
|
}
|
|
|
|
let mr: GitLabMergeRequest;
|
|
try {
|
|
mr = await getMR(config.repository, iid);
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to get existing reviewers');
|
|
return;
|
|
}
|
|
|
|
mr.reviewers = mr.reviewers ?? [];
|
|
const existingReviewers = mr.reviewers.map((r) => r.username);
|
|
const existingReviewerIDs = mr.reviewers.map((r) => r.id);
|
|
|
|
// Figure out which reviewers (of the ones we want to add) are not already on the MR as a reviewer
|
|
const newReviewers = reviewers.filter((r) => !existingReviewers.includes(r));
|
|
|
|
// Gather the IDs for all the reviewers we want to add
|
|
let newReviewerIDs: number[];
|
|
try {
|
|
newReviewerIDs = (
|
|
await p.all(
|
|
newReviewers.map((r) => async () => {
|
|
try {
|
|
return [await getUserID(r)];
|
|
} catch (err) {
|
|
// Unable to fetch userId, try resolve as a group
|
|
return getMemberUserIDs(r);
|
|
}
|
|
})
|
|
)
|
|
).flat();
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to get IDs of the new reviewers');
|
|
return;
|
|
}
|
|
|
|
// Multiple groups may have the same members, so
|
|
// filter out non-distinct values
|
|
newReviewerIDs = [...new Set(newReviewerIDs)];
|
|
|
|
try {
|
|
await updateMR(config.repository, iid, {
|
|
reviewer_ids: [...existingReviewerIDs, ...newReviewerIDs],
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to add reviewers');
|
|
}
|
|
}
|
|
|
|
export async function deleteLabel(
|
|
issueNo: number,
|
|
label: string
|
|
): Promise<void> {
|
|
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<GitlabComment[]> {
|
|
// 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<GitlabComment[]>(url, { paginate: true })
|
|
).body;
|
|
logger.debug(`Found ${comments.length} comments`);
|
|
return comments;
|
|
}
|
|
|
|
async function addComment(issueNo: number, body: string): Promise<void> {
|
|
// 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<void> {
|
|
// 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<void> {
|
|
// 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<boolean> {
|
|
const sanitizedContent = sanitize(content);
|
|
const massagedTopic = topic
|
|
? topic
|
|
.replace(regEx(/Pull Request/g), 'Merge Request')
|
|
.replace(regEx(/PR/g), 'MR')
|
|
: topic;
|
|
const comments = await getComments(number);
|
|
let body: string;
|
|
let commentId: number | undefined;
|
|
let commentNeedsUpdating: boolean | undefined;
|
|
// TODO: types (#7154)
|
|
if (topic) {
|
|
logger.debug(`Ensuring comment "${massagedTopic!}" in #${number}`);
|
|
body = `### ${topic}\n\n${sanitizedContent}`;
|
|
body = body
|
|
.replace(regEx(/Pull Request/g), 'Merge Request')
|
|
.replace(regEx(/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;
|
|
}
|
|
|
|
export async function ensureCommentRemoval(
|
|
deleteConfig: EnsureCommentRemovalConfig
|
|
): Promise<void> {
|
|
const { number: issueNo } = deleteConfig;
|
|
const key =
|
|
deleteConfig.type === 'by-topic'
|
|
? deleteConfig.topic
|
|
: deleteConfig.content;
|
|
logger.debug(`Ensuring comment "${key}" in #${issueNo} is removed`);
|
|
|
|
const comments = await getComments(issueNo);
|
|
let commentId: number | null | undefined = null;
|
|
|
|
if (deleteConfig.type === 'by-topic') {
|
|
const byTopic = (comment: GitlabComment): boolean =>
|
|
comment.body.startsWith(`### ${deleteConfig.topic}\n\n`);
|
|
commentId = comments.find(byTopic)?.id;
|
|
} else if (deleteConfig.type === 'by-content') {
|
|
const byContent = (comment: GitlabComment): boolean =>
|
|
comment.body.trim() === deleteConfig.content;
|
|
commentId = comments.find(byContent)?.id;
|
|
}
|
|
|
|
if (commentId) {
|
|
await deleteComment(issueNo, commentId);
|
|
}
|
|
}
|
|
|
|
export function getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
export async function filterUnavailableUsers(
|
|
users: string[]
|
|
): Promise<string[]> {
|
|
const filteredUsers: string[] = [];
|
|
for (const user of users) {
|
|
if (!(await isUserBusy(user))) {
|
|
filteredUsers.push(user);
|
|
}
|
|
}
|
|
return filteredUsers;
|
|
}
|