renovate/lib/platform/bitbucket/index.js

647 lines
16 KiB
JavaScript
Raw Normal View History

const parseDiff = require('parse-diff');
const api = require('./bb-got-wrapper');
const utils = require('./utils');
const hostRules = require('../../util/host-rules');
const GitStorage = require('../git/storage');
const { readOnlyIssueBody } = require('../utils/read-only-issue-body');
2019-01-10 11:40:56 +00:00
const { appSlug } = require('../../config/app-strings');
let config = {};
module.exports = {
// Initialization
initPlatform,
getRepos,
cleanRepo,
initRepo,
getRepoStatus,
getRepoForceRebase,
setBaseBranch,
setBranchPrefix,
// Search
getFileList,
// Branch
branchExists,
getAllRenovateBranches,
isBranchStale,
getBranchPr,
getBranchStatus,
getBranchStatusCheck,
setBranchStatus,
deleteBranch,
mergeBranch,
getBranchLastCommitTime,
// Issue
2018-10-03 13:47:03 +00:00
findIssue,
ensureIssue,
ensureIssueClosing,
addAssignees,
addReviewers,
deleteLabel,
getIssueList,
// Comments
ensureComment,
ensureCommentRemoval,
// PR
getPrList,
findPr,
createPr,
getPr,
getPrFiles,
updatePr,
mergePr,
getPrBody,
// file
commitFilesToBranch,
getFile,
// Commits
getCommitMessages,
// vulnerability alerts
getVulnerabilityAlerts,
};
function initPlatform({ endpoint, username, password }) {
if (!(username && password)) {
throw new Error(
'Init: You must configure a Bitbucket username and password'
);
}
if (endpoint && endpoint !== 'https://api.bitbucket.org/') {
throw new Error(
'Init: Bitbucket Cloud endpoint can only be https://api.bitbucket.org/'
);
}
// TODO: Add a connection check that endpoint/username/password combination are valid
const res = {
endpoint: 'https://api.bitbucket.org/',
};
logger.info('Using default Bitbucket Cloud endpoint: ' + res.endpoint);
return res;
}
// Get all repositories that the user has access to
async function getRepos() {
logger.info('Autodiscovering Bitbucket Cloud repositories');
try {
const repos = await utils.accumulateValues(
`/2.0/repositories/?role=contributor`
);
return repos.map(repo => repo.full_name);
} catch (err) /* istanbul ignore next */ {
logger.error({ err }, `bitbucket getRepos error`);
throw err;
}
}
// Initialize bitbucket by getting base branch and SHA
async function initRepo({ repository, localDir }) {
logger.debug(`initRepo("${repository}")`);
const opts = hostRules.find({ platform: 'bitbucket' });
api.reset();
config = {};
// TODO: get in touch with @rarkins about lifting up the caching into the app layer
config.repository = repository;
const platformConfig = {};
// Always gitFs
const url = GitStorage.getUrl({
gitFs: 'https',
auth: `${opts.username}:${opts.password}`,
hostname: 'bitbucket.org',
repository,
});
config.storage = new GitStorage();
await config.storage.initRepo({
...config,
localDir,
url,
});
try {
const info = utils.repoInfoTransformer(
(await api.get(`/2.0/repositories/${repository}`)).body
);
platformConfig.privateRepo = info.privateRepo;
platformConfig.isFork = info.isFork;
platformConfig.repoFullName = info.repoFullName;
config.owner = info.owner;
logger.debug(`${repository} owner = ${config.owner}`);
config.defaultBranch = info.mainbranch;
config.baseBranch = config.defaultBranch;
config.mergeMethod = info.mergeMethod;
} catch (err) /* istanbul ignore next */ {
if (err.statusCode === 404) {
throw new Error('not-found');
}
logger.info({ err }, 'Unknown Bitbucket initRepo error');
throw err;
}
delete config.prList;
delete config.fileList;
await Promise.all([getPrList(), getFileList()]);
return platformConfig;
}
// Returns true if repository has rule enforcing PRs are up-to-date with base branch before merging
function getRepoForceRebase() {
// BB doesnt have an option to flag staled branches
return false;
}
2019-05-15 11:04:16 +00:00
async function setBaseBranch(branchName = config.baseBranch) {
logger.debug(`Setting baseBranch to ${branchName}`);
config.baseBranch = branchName;
delete config.baseCommitSHA;
delete config.fileList;
await config.storage.setBaseBranch(branchName);
await getFileList(branchName);
}
// istanbul ignore next
function setBranchPrefix(branchPrefix) {
return config.storage.setBranchPrefix(branchPrefix);
}
// Search
// Get full file list
function getFileList(branchName) {
return config.storage.getFileList(branchName);
}
// Branch
// Returns true if branch exists, otherwise false
function branchExists(branchName) {
return config.storage.branchExists(branchName);
}
function getAllRenovateBranches(branchPrefix) {
return config.storage.getAllRenovateBranches(branchPrefix);
}
function isBranchStale(branchName) {
return config.storage.isBranchStale(branchName);
}
function getFile(filePath, branchName) {
return config.storage.getFile(filePath, branchName);
}
async function deleteBranch(branchName, closePr) {
if (closePr) {
const pr = await findPr(branchName, null, 'open');
if (pr) {
await api.post(
`/2.0/repositories/${config.repository}/pullrequests/${
pr.number
}/decline`
);
}
}
return config.storage.deleteBranch(branchName);
}
function getBranchLastCommitTime(branchName) {
return config.storage.getBranchLastCommitTime(branchName);
}
// istanbul ignore next
function getRepoStatus() {
return config.storage.getRepoStatus();
}
function mergeBranch(branchName) {
return config.storage.mergeBranch(branchName);
}
function commitFilesToBranch(
branchName,
files,
message,
parentBranch = config.baseBranch
) {
return config.storage.commitFilesToBranch(
branchName,
files,
message,
parentBranch
);
}
function getCommitMessages() {
return config.storage.getCommitMessages();
}
// Returns the Pull Request for a branch. Null if not exists.
async function getBranchPr(branchName) {
logger.debug(`getBranchPr(${branchName})`);
const existingPr = await findPr(branchName, null, 'open');
return existingPr ? getPr(existingPr.number) : null;
}
// 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
logger.debug('Status checks disabled = returning "success"');
return 'success';
}
if (requiredStatusChecks.length) {
// This is Unsupported
logger.warn({ requiredStatusChecks }, `Unsupported requiredStatusChecks`);
return 'failed';
}
const sha = await getBranchCommit(branchName);
const statuses = await utils.accumulateValues(
`/2.0/repositories/${config.repository}/commit/${sha}/statuses`
);
const noOfFailures = statuses.filter(status => status.state === 'FAILED')
.length;
2018-09-24 09:47:49 +00:00
logger.debug(
{ branch: branchName, sha, statuses },
'branch status check result'
);
if (noOfFailures) {
return 'failed';
}
return 'success';
}
async function getBranchStatusCheck(branchName, context) {
const sha = await getBranchCommit(branchName);
const statuses = await utils.accumulateValues(
`/2.0/repositories/${config.repository}/commit/${sha}/statuses`
);
const bbState = (statuses.find(status => status.key === context) || {}).state;
return (
Object.keys(utils.buildStates).find(
stateKey => utils.buildStates[stateKey] === bbState
) || null
);
}
async function setBranchStatus(
branchName,
context,
description,
state,
targetUrl
) {
const sha = await getBranchCommit(branchName);
// TargetUrl can not be empty so default to bitbucket
const url = targetUrl || 'http://bitbucket.org';
const body = {
name: context,
state: utils.buildStates[state],
key: context,
description,
url,
};
await api.post(
`/2.0/repositories/${config.repository}/commit/${sha}/statuses/build`,
{ body }
);
}
async function findOpenIssues(title) {
try {
const currentUser = (await api.get('/2.0/user')).body.username;
const filter = encodeURIComponent(
[
`title=${JSON.stringify(title)}`,
'(state = "new" OR state = "open")',
`reporter.username="${currentUser}"`,
].join(' AND ')
);
return (
(await api.get(
`/2.0/repositories/${config.repository}/issues?q=${filter}`
)).body.values || []
);
} catch (err) /* istanbul ignore next */ {
logger.warn('Error finding issues');
return [];
}
2018-10-03 13:47:03 +00:00
}
async function findIssue(title) {
logger.debug(`findIssue(${title})`);
const issues = await findOpenIssues(title);
if (!issues.length) {
return null;
}
const [issue] = issues;
return {
number: issue.id,
body: issue.content && issue.content.raw,
};
}
async function closeIssue(issueNumber) {
await api.put(
`/2.0/repositories/${config.repository}/issues/${issueNumber}`,
{
body: { state: 'closed' },
}
);
}
async function ensureIssue(title, body) {
logger.debug(`ensureIssue()`);
try {
const issues = await findOpenIssues(title);
if (issues.length) {
// Close any duplicates
for (const issue of issues.slice(1)) {
await closeIssue(issue.id);
}
const [issue] = issues;
if (String(issue.content.raw).trim() !== body.trim()) {
logger.info('Issue updated');
await api.put(
`/2.0/repositories/${config.repository}/issues/${issue.id}`,
{
body: {
content: { raw: readOnlyIssueBody(body), markup: 'markdown' },
},
}
);
return 'updated';
}
} else {
logger.info('Issue created');
await api.post(`/2.0/repositories/${config.repository}/issues`, {
body: {
title,
content: { raw: readOnlyIssueBody(body), markup: 'markdown' },
},
});
return 'created';
}
} catch (err) /* istanbul ignore next */ {
if (err.message.startsWith('Repository has no issue tracker.')) {
logger.info(
`Issues are disabled, so could not create issue: ${err.message}`
);
} else {
logger.warn({ err }, 'Could not ensure issue');
}
}
return null;
}
// istanbul ignore next
function getIssueList() {
logger.debug(`getIssueList()`);
// TODO: Needs implementation
return [];
}
async function ensureIssueClosing(title) {
const issues = await findOpenIssues(title);
for (const issue of issues) {
await closeIssue(issue.id);
}
}
function addAssignees() {
// Bitbucket supports "participants" and "reviewers" so does not seem to have the concept of "assignee"
logger.warn('Cannot add assignees');
return Promise.resolve();
}
async function addReviewers(prId, reviewers) {
logger.debug(`Adding reviewers ${reviewers} to #${prId}`);
const { title } = await getPr(prId);
const body = {
title,
reviewers: reviewers.map(username => ({ username })),
};
await api.put(`/2.0/repositories/${config.repository}/pullrequests/${prId}`, {
body,
});
}
// istanbul ignore next
function deleteLabel() {
throw new Error('deleteLabel not implemented');
}
function ensureComment() {
// https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment
logger.warn('Comment functionality not implemented yet');
return Promise.resolve();
}
function ensureCommentRemoval() {
// The api does not support removing comments
return Promise.resolve();
}
// istanbul ignore next
function matchesState(state, desiredState) {
if (desiredState === 'all') {
return true;
}
if (desiredState[0] === '!') {
return state !== desiredState.substring(1);
}
return state === desiredState;
}
async function findPr(branchName, prTitle, state = 'all') {
logger.debug(`findPr(${branchName}, ${prTitle}, ${state})`);
const prList = await getPrList();
const pr = prList.find(
p =>
p.branchName === branchName &&
(!prTitle || p.title === prTitle) &&
matchesState(p.state, state)
);
if (pr) {
logger.debug(`Found PR #${pr.number}`);
}
return pr;
}
// Creates PR and returns PR number
async function createPr(
branchName,
title,
description,
labels,
useDefaultBranch = true
) {
// labels is not supported in Bitbucket: https://bitbucket.org/site/master/issues/11976/ability-to-add-labels-to-pull-requests-bb
const base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
logger.debug({ repository: config.repository, title, base }, 'Creating PR');
const body = {
title,
description,
source: {
branch: {
name: branchName,
},
},
destination: {
branch: {
name: base,
},
},
close_source_branch: true,
};
const prInfo = (await api.post(
`/2.0/repositories/${config.repository}/pullrequests`,
{ body }
)).body;
const pr = { number: prInfo.id, displayNumber: `Pull Request #${prInfo.id}` };
// istanbul ignore if
if (config.prList) {
config.prList.push(pr);
}
return pr;
}
async function isPrConflicted(prNo) {
const diff = (await api.get(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/diff`,
{ json: false }
)).body;
return utils.isConflicted(parseDiff(diff));
}
// Gets details for a PR
async function getPr(prNo) {
const pr = (await api.get(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}`
)).body;
// istanbul ignore if
if (!pr) {
return null;
}
const res = {
displayNumber: `Pull Request #${pr.id}`,
...utils.prInfo(pr),
};
if (utils.prStates.open.includes(pr.state)) {
res.isConflicted = await isPrConflicted(prNo);
const commits = await utils.accumulateValues(pr.links.commits.href);
if (commits.length === 1) {
res.canRebase = true;
}
}
if (await branchExists(pr.source.branch.name)) {
res.isStale = await isBranchStale(pr.source.branch.name);
}
return res;
}
// Return a list of all modified files in a PR
async function getPrFiles(prNo) {
logger.debug({ prNo }, 'getPrFiles');
const diff = (await api.get(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/diff`,
{ json: false }
)).body;
const files = parseDiff(diff).map(file => file.to);
return files;
}
async function updatePr(prNo, title, description) {
logger.debug(`updatePr(${prNo}, ${title}, body)`);
await api.put(`/2.0/repositories/${config.repository}/pullrequests/${prNo}`, {
body: { title, description },
});
}
async function mergePr(prNo, branchName) {
logger.debug(`mergePr(${prNo}, ${branchName})`);
try {
await api.post(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/merge`,
{
body: {
close_source_branch: true,
merge_strategy: 'merge_commit',
message: 'auto merged',
},
}
);
delete config.baseCommitSHA;
logger.info('Automerging succeeded');
} catch (err) /* istanbul ignore next */ {
return false;
}
return true;
}
function getPrBody(input) {
// Remove any HTML we use
return input
.replace(/<\/?summary>/g, '**')
.replace(/<\/?details>/g, '')
2019-01-10 11:40:56 +00:00
.replace(new RegExp(`\n---\n\n.*?<!-- ${appSlug}-rebase -->.*?\n`), '')
.substring(0, 50000);
}
// Return the commit SHA for a branch
async function getBranchCommit(branchName) {
try {
const branch = (await api.get(
`/2.0/repositories/${config.repository}/refs/branches/${branchName}`
)).body;
return branch.target.hash;
} catch (err) /* istanbul ignore next */ {
logger.debug({ err }, `getBranchCommit('${branchName}') failed'`);
return null;
}
}
// Pull Request
2018-12-08 05:38:22 +00:00
async function getPrList() {
logger.debug('getPrList()');
if (!config.prList) {
logger.debug('Retrieving PR list');
2018-12-08 05:38:22 +00:00
let url = `/2.0/repositories/${config.repository}/pullrequests?`;
url += utils.prStates.all.map(state => 'state=' + state).join('&');
const prs = await utils.accumulateValues(url, undefined, undefined, 50);
config.prList = prs.map(utils.prInfo);
logger.info({ length: config.prList.length }, 'Retrieved Pull Requests');
}
return config.prList;
}
function cleanRepo() {
// istanbul ignore if
if (config.storage && config.storage.cleanRepo) {
config.storage.cleanRepo();
}
api.reset();
config = {};
}
function getVulnerabilityAlerts() {
return [];
}