renovate/lib/platform/gitlab/index.js

733 lines
19 KiB
JavaScript
Raw Normal View History

const is = require('@sindresorhus/is');
const get = require('./gl-got-wrapper');
const addrs = require('email-addresses');
2017-02-11 07:14:19 +00:00
let config = {};
2017-02-11 07:14:19 +00:00
module.exports = {
getRepos,
cleanRepo: () => undefined,
2017-02-11 07:14:19 +00:00
initRepo,
getRepoForceRebase,
setBaseBranch,
2017-02-11 07:14:19 +00:00
// Search
getFileList,
2017-02-11 07:14:19 +00:00
// Branch
branchExists,
getAllRenovateBranches,
isBranchStale,
2017-02-11 07:14:19 +00:00
getBranchPr,
getBranchStatus,
getBranchStatusCheck,
setBranchStatus,
deleteBranch,
mergeBranch,
getBranchLastCommitTime,
2017-02-11 07:14:19 +00:00
// issue
ensureIssue,
ensureIssueClosing,
2017-02-11 07:14:19 +00:00
addAssignees,
addReviewers,
// Comments
ensureComment,
ensureCommentRemoval,
2017-02-11 07:14:19 +00:00
// PR
getPrList,
2017-02-11 07:14:19 +00:00
findPr,
createPr,
getPr,
getPrFiles,
2017-02-11 07:14:19 +00:00
updatePr,
mergePr,
2017-02-11 07:14:19 +00:00
// file
commitFilesToBranch,
getFile,
// commits
getCommitMessages,
2017-02-11 07:14:19 +00:00
};
// Get all repositories that the user has access to
async function getRepos(token, endpoint) {
2018-04-04 11:38:06 +00:00
logger.info('Autodiscovering GitLab repositories');
logger.debug('getRepos(token, endpoint)');
if (token) {
process.env.GITLAB_TOKEN = token;
} else if (!process.env.GITLAB_TOKEN) {
throw new Error('No token found for getRepos');
}
if (endpoint) {
process.env.GITLAB_ENDPOINT = endpoint;
}
try {
const url = `projects?membership=true&per_page=100`;
const res = await get(url, { paginate: true });
logger.info(`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) {
return str ? str.replace(/\//g, '%2F') : str;
}
2017-02-11 07:14:19 +00:00
// Initialize GitLab by getting base branch
async function initRepo({ repository, token, endpoint }) {
2017-02-11 07:14:19 +00:00
if (token) {
process.env.GITLAB_TOKEN = token;
} else if (!process.env.GITLAB_TOKEN) {
throw new Error(`No token found for GitLab repository ${repository}`);
2017-02-11 07:14:19 +00:00
}
if (token) {
process.env.GITLAB_TOKEN = token;
}
if (endpoint) {
process.env.GITLAB_ENDPOINT = endpoint;
}
config = {};
get.reset();
config.repository = urlEscape(repository);
2017-02-11 07:14:19 +00:00
try {
const res = await get(`projects/${config.repository}`);
config.defaultBranch = res.body.default_branch;
config.baseBranch = config.defaultBranch;
logger.debug(`${repository} default branch = ${config.baseBranch}`);
// Discover our user email
config.email = (await get(`user`)).body.email;
delete config.prList;
delete config.fileList;
await Promise.all([getPrList(), getFileList()]);
2017-02-11 07:14:19 +00:00
} catch (err) {
logger.error({ err }, `GitLab init error`);
2017-02-11 07:14:19 +00:00
throw err;
}
return {};
2017-02-11 07:14:19 +00:00
}
function getRepoForceRebase() {
return false;
}
async function setBaseBranch(branchName) {
if (branchName) {
logger.debug(`Setting baseBranch to ${branchName}`);
config.baseBranch = branchName;
delete config.fileList;
await getFileList(branchName);
}
}
2017-02-11 07:14:19 +00:00
// Search
// Get full file list
async function getFileList(branchName = config.baseBranch) {
if (config.fileList) {
return config.fileList;
}
try {
const res = await get(
`projects/${
config.repository
}/repository/tree?ref=${branchName}&recursive=true&per_page=100`,
{ paginate: true }
);
config.fileList = res.body
.filter(item => item.type === 'blob' && item.mode !== '120000')
.map(item => item.path)
.sort();
logger.debug(`Retrieved fileList with length ${config.fileList.length}`);
} catch (err) {
logger.info('Error retrieving git tree - no files detected');
config.fileList = [];
}
return config.fileList;
}
2017-02-11 07:14:19 +00:00
// Branch
// Returns true if branch exists, otherwise false
async function branchExists(branchName) {
logger.debug(`Checking if branch exists: ${branchName}`);
try {
const url = `projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`;
const res = await get(url);
2017-02-11 07:14:19 +00:00
if (res.statusCode === 200) {
logger.debug('Branch exists');
return true;
}
// This probably shouldn't happen
2017-04-21 08:12:41 +00:00
logger.debug("Branch doesn't exist");
2017-02-11 07:14:19 +00:00
return false;
} catch (error) {
if (error.statusCode === 404) {
// If file not found, then return false
2017-04-21 08:12:41 +00:00
logger.debug("Branch doesn't exist");
2017-02-11 07:14:19 +00:00
return false;
}
// Propagate if it's any other error
throw error;
}
}
async function getAllRenovateBranches(branchPrefix) {
logger.debug(`getAllRenovateBranches(${branchPrefix})`);
const allBranches = await get(
`projects/${config.repository}/repository/branches`
);
return allBranches.body.reduce((arr, branch) => {
if (branch.name.startsWith(branchPrefix)) {
arr.push(branch.name);
}
return arr;
}, []);
}
async function isBranchStale(branchName) {
logger.debug(`isBranchStale(${branchName})`);
const branchDetails = await getBranchDetails(branchName);
logger.trace({ branchDetails }, 'branchDetails');
const parentSha = branchDetails.body.commit.parent_ids[0];
logger.debug(`parentSha=${parentSha}`);
const baseCommitSHA = await getBaseCommitSHA();
logger.debug(`baseCommitSHA=${baseCommitSHA}`);
// Return true if the SHAs don't match
return parentSha !== baseCommitSHA;
}
2017-02-11 07:14:19 +00:00
// Returns the Pull Request for a branch. Null if not exists.
async function getBranchPr(branchName) {
logger.debug(`getBranchPr(${branchName})`);
if (!(await branchExists(branchName))) {
return null;
}
const urlString = `projects/${
config.repository
}/merge_requests?state=opened&per_page=100`;
const res = await get(urlString, { paginate: true });
2017-02-11 07:14:19 +00:00
logger.debug(`Got res with ${res.body.length} results`);
let pr = null;
2017-04-21 08:12:41 +00:00
res.body.forEach(result => {
2017-02-11 07:14:19 +00:00
if (result.source_branch === branchName) {
pr = result;
}
});
if (!pr) {
return null;
}
return getPr(pr.iid);
2017-02-11 07:14:19 +00:00
}
// Returns the combined status for a branch.
async function getBranchStatus(branchName, requiredStatusChecks) {
logger.debug(`getBranchStatus(${branchName})`);
if (!requiredStatusChecks) {
// null means disable status checks, so it always succeeds
return 'success';
}
if (requiredStatusChecks.length) {
// This is Unsupported
logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`);
return 'failed';
}
// First, get the branch to find the commit SHA
let url = `projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`;
let res = await get(url);
const branchSha = res.body.commit.id;
// Now, check the statuses for that commit
url = `projects/${
config.repository
}/repository/commits/${branchSha}/statuses`;
res = await get(url);
logger.debug(`Got res with ${res.body.length} results`);
if (res.body.length === 0) {
// Return 'pending' if we have no status checks
return 'pending';
}
let status = 'success';
// Return 'success' if all are success
2017-04-21 08:12:41 +00:00
res.body.forEach(check => {
// If one is failed then don't overwrite that
if (status !== 'failure') {
if (!check.allow_failure) {
if (check.status === 'failed') {
status = 'failure';
} else if (check.status !== 'success') {
({ status } = check);
}
}
}
});
return status;
}
async function getBranchStatusCheck(branchName, context) {
// First, get the branch to find the commit SHA
let url = `projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`;
let res = await get(url);
const branchSha = res.body.commit.id;
// Now, check the statuses for that commit
url = `projects/${
config.repository
}/repository/commits/${branchSha}/statuses`;
res = await get(url);
logger.debug(`Got res with ${res.body.length} results`);
for (const check of res.body) {
if (check.name === context) {
return check.state;
}
}
return null;
}
async function setBranchStatus(
branchName,
context,
description,
state,
targetUrl
) {
// First, get the branch to find the commit SHA
let url = `projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`;
const res = await get(url);
const branchSha = res.body.commit.id;
// Now, check the statuses for that commit
url = `projects/${config.repository}/statuses/${branchSha}`;
const options = {
state,
description,
context,
};
if (targetUrl) {
options.target_url = targetUrl;
}
await get.post(url, { body: options });
}
async function deleteBranch(branchName, closePr = false) {
if (closePr) {
logger.debug('Closing PR');
const pr = await getBranchPr(branchName);
// istanbul ignore if
if (pr) {
await get.put(
`projects/${config.repository}/merge_requests/${pr.number}`,
{
body: {
state_event: 'close',
},
}
);
}
}
await get.delete(
`projects/${config.repository}/repository/branches/${urlEscape(branchName)}`
);
}
function mergeBranch() {
logger.warn('Unimplemented in GitLab: mergeBranch');
}
async function getBranchLastCommitTime(branchName) {
try {
const res = await get(
`projects/${config.repository}/repository/commits?ref_name=${urlEscape(
branchName
)}`
);
return new Date(res.body[0].committed_date);
} catch (err) {
logger.error({ err }, `getBranchLastCommitTime error`);
return new Date();
}
}
2017-02-11 07:14:19 +00:00
// Issue
function ensureIssue() {
// istanbul ignore next
logger.warn(`ensureIssue() is not implemented`);
}
function ensureIssueClosing() {}
async function addAssignees(iid, assignees) {
logger.debug(`Adding assignees ${assignees} to #${iid}`);
2017-02-11 07:14:19 +00:00
if (assignees.length > 1) {
logger.warn('Cannot assign more than one assignee to Merge Requests');
}
try {
const assigneeId = (await get(`users?username=${assignees[0]}`)).body[0].id;
let url = `projects/${config.repository}/merge_requests/${iid}`;
url += `?assignee_id=${assigneeId}`;
await get.put(url);
} catch (err) {
logger.error({ iid, assignees }, 'Failed to add assignees');
2017-02-11 07:14:19 +00:00
}
}
function addReviewers(iid, reviewers) {
logger.debug(`addReviewers('${iid}, '${reviewers})`);
logger.warn('Unimplemented in GitLab: approvals');
2017-02-11 07:14:19 +00:00
}
async function getComments(issueNo) {
// GET /api/v4/projects/:owner/:repo/merge_requests/:number/notes
logger.debug(`Getting comments for #${issueNo}`);
const url = `/api/v4/projects/${
config.repository
}/merge_requests/${issueNo}/notes`;
const comments = (await get(url, { paginate: true })).body;
logger.debug(`Found ${comments.length} comments`);
return comments;
}
async function addComment(issueNo, body) {
// POST /api/v4/projects/:owner/:repo/merge_requests/:number/notes
await get.post(
`/api/v4/projects/${config.repository}/merge_requests/${issueNo}/notes`,
{
body: { body },
}
);
}
async function editComment(issueNo, commentId, body) {
// PATCH /api/v4/projects/:owner/:repo/merge_requests/:number/notes/:id
await get.patch(
`/api/v4/projects/${
config.repository
}/merge_requests/${issueNo}/notes/${commentId}`,
{
body: { body },
}
);
}
async function deleteComment(issueNo, commentId) {
// DELETE /api/v4/projects/:owner/:repo/merge_requests/:number/notes/:id
await get.delete(
`/api/v4/projects/${
config.repository
}/merge_requests/${issueNo}/notes/${commentId}`
);
}
async function ensureComment(issueNo, topic, content) {
const comments = await getComments(issueNo);
let body;
let commentId;
let commentNeedsUpdating;
if (topic) {
logger.debug(`Ensuring comment "${topic}" in #${issueNo}`);
body = `### ${topic}\n\n${content}`;
comments.forEach(comment => {
if (comment.body.startsWith(`### ${topic}\n\n`)) {
commentId = comment.id;
commentNeedsUpdating = comment.body !== body;
}
});
} else {
logger.debug(`Ensuring content-only comment in #${issueNo}`);
body = `${content}`;
comments.forEach(comment => {
if (comment.body === body) {
commentId = comment.id;
commentNeedsUpdating = false;
}
});
}
if (!commentId) {
await addComment(issueNo, body);
logger.info({ repository: config.repository, issueNo }, 'Added comment');
} else if (commentNeedsUpdating) {
await editComment(issueNo, commentId, body);
logger.info({ repository: config.repository, issueNo }, 'Updated comment');
} else {
logger.debug('Comment is already update-to-date');
}
}
async function ensureCommentRemoval(issueNo, topic) {
logger.debug(`Ensuring comment "${topic}" in #${issueNo} is removed`);
const comments = await getComments(issueNo);
let commentId;
comments.forEach(comment => {
if (comment.body.startsWith(`### ${topic}\n\n`)) {
commentId = comment.id;
}
});
if (commentId) {
await deleteComment(issueNo, commentId);
}
}
async function getPrList() {
if (!config.prList) {
const urlString = `projects/${
config.repository
}/merge_requests?per_page=100`;
const res = await get(urlString, { paginate: true });
config.prList = res.body.map(pr => ({
number: pr.iid,
branchName: pr.source_branch,
title: pr.title,
state: pr.state === 'opened' ? 'open' : pr.state,
createdAt: pr.created_at,
}));
}
return config.prList;
}
function matchesState(state, desiredState) {
if (desiredState === 'all') {
return true;
}
if (desiredState[0] === '!') {
return state !== desiredState.substring(1);
}
return state === desiredState;
}
2017-02-11 07:14:19 +00:00
async function findPr(branchName, prTitle, state = 'all') {
logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`);
const prList = await getPrList();
return prList.find(
p =>
p.branchName === branchName &&
(!prTitle || p.title === prTitle) &&
matchesState(p.state, state)
);
2017-02-11 07:14:19 +00:00
}
// Pull Request
async function createPr(
branchName,
title,
description,
labels,
useDefaultBranch
) {
const targetBranch = useDefaultBranch
? config.defaultBranch
: config.baseBranch;
2017-02-11 07:14:19 +00:00
logger.debug(`Creating Merge Request: ${title}`);
const res = await get.post(`projects/${config.repository}/merge_requests`, {
2017-02-11 07:14:19 +00:00
body: {
source_branch: branchName,
target_branch: targetBranch,
remove_source_branch: true,
2017-02-11 07:14:19 +00:00
title,
description,
labels: is.array(labels) ? labels.join(',') : null,
2017-02-11 07:14:19 +00:00
},
});
const pr = res.body;
pr.number = pr.iid;
pr.branchName = branchName;
2017-02-11 07:14:19 +00:00
pr.displayNumber = `Merge Request #${pr.iid}`;
return pr;
}
async function getPr(iid) {
logger.debug(`getPr(${iid})`);
const url = `projects/${config.repository}/merge_requests/${iid}`;
const pr = (await get(url)).body;
2017-02-11 07:14:19 +00:00
// Harmonize fields with GitHub
2018-02-19 19:01:10 +00:00
pr.branchName = pr.source_branch;
pr.number = pr.iid;
2017-02-11 07:14:19 +00:00
pr.displayNumber = `Merge Request #${pr.iid}`;
pr.body = pr.description;
if (pr.merge_status === 'cannot_be_merged') {
logger.debug('pr cannot be merged');
pr.canMerge = false;
2017-02-11 07:14:19 +00:00
pr.isUnmergeable = true;
} else {
// Actually.. we can't be sure
pr.canMerge = true;
2017-02-11 07:14:19 +00:00
}
// 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 get(branchUrl)).body;
if (
branch &&
branch.commit &&
branch.commit.author_email === config.email
) {
pr.canRebase = true;
}
} catch (err) {
logger.warn({ err }, 'Error getting PR branch');
pr.isUnmergeable = true;
}
2017-02-11 07:14:19 +00:00
return pr;
}
// Return a list of all modified files in a PR
async function getPrFiles(mrNo) {
logger.debug({ mrNo }, 'getPrFiles');
if (!mrNo) {
return [];
}
const files = (await get(
`/api/v4/projects/${config.repository}/merge_requests/${mrNo}/changes`
)).body;
return files.map(f => f.filename);
}
// istanbul ignore next
async function reopenPr(iid) {
await get.put(`projects/${config.repository}/merge_requests/${iid}`, {
body: {
state_event: 'reopen',
},
});
}
async function updatePr(iid, title, description) {
await get.put(`projects/${config.repository}/merge_requests/${iid}`, {
2017-02-11 07:14:19 +00:00
body: {
title,
description,
2017-02-11 07:14:19 +00:00
},
});
}
async function mergePr(iid) {
await get.put(`projects/${config.repository}/merge_requests/${iid}/merge`, {
body: {
should_remove_source_branch: true,
},
});
return true;
}
2017-02-11 07:14:19 +00:00
// Generic File operations
async function getFile(filePath, branchName) {
logger.debug(`getFile(filePath=${filePath}, branchName=${branchName})`);
if (!branchName || branchName === config.baseBranch) {
if (config.fileList && !config.fileList.includes(filePath)) {
return null;
}
}
2017-02-11 07:14:19 +00:00
try {
const url = `projects/${config.repository}/repository/files/${urlEscape(
filePath
)}?ref=${branchName || config.baseBranch}`;
const res = await get(url);
return Buffer.from(res.body.content, 'base64').toString();
2017-02-11 07:14:19 +00:00
} catch (error) {
if (error.statusCode === 404) {
// If file not found, then return null JSON
return null;
}
// Propagate if it's any other error
throw error;
}
}
// Add a new commit, create branch if not existing
async function commitFilesToBranch(
branchName,
files,
message,
parentBranch = config.baseBranch,
gitAuthor
2017-04-21 08:12:41 +00:00
) {
logger.debug(
`commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`
);
const opts = {
body: {
branch: branchName,
commit_message: message,
start_branch: parentBranch,
actions: [],
},
};
try {
if (gitAuthor) {
logger.debug({ gitAuthor }, 'Found gitAuthor');
const { name, address } = addrs.parseOneAddress(gitAuthor);
if (name && address) {
opts.body.author_name = name;
opts.body.author_email = address;
}
}
} catch (err) {
logger.warn({ gitAuthor }, 'Error parsing gitAuthor');
}
2017-02-11 07:14:19 +00:00
for (const file of files) {
const action = {
file_path: file.name,
content: Buffer.from(file.contents).toString('base64'),
encoding: 'base64',
};
action.action = (await getFile(file.name)) ? 'update' : 'create';
opts.body.actions.push(action);
2017-02-11 07:14:19 +00:00
}
try {
if (await branchExists(branchName)) {
logger.debug('Deleting existing branch');
await deleteBranch(branchName);
}
} catch (err) {
// istanbul ignore next
logger.info(`Ignoring branch deletion failure`);
}
logger.debug('Adding commits');
await get.post(`projects/${config.repository}/repository/commits`, opts);
// Reopen PR if it previousluy existed and was closed by GitLab when we deleted branch
const pr = await getBranchPr(branchName);
// istanbul ignore if
if (pr) {
logger.debug('Reopening PR');
await reopenPr(pr.number);
}
2017-02-11 07:14:19 +00:00
}
// GET /projects/:id/repository/commits
async function getCommitMessages() {
logger.debug('getCommitMessages');
try {
const res = await get(`projects/${config.repository}/repository/commits`);
return res.body.map(commit => commit.title);
} catch (err) {
logger.error({ err }, `getCommitMessages error`);
return [];
}
}
function getBranchDetails(branchName) {
const url = `/projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`;
return get(url);
}
async function getBaseCommitSHA() {
if (!config.baseCommitSHA) {
const branchDetails = await getBranchDetails(config.baseBranch);
config.baseCommitSHA = branchDetails.body.commit.id;
}
return config.baseCommitSHA;
}