feat(bitbucket): git fs (#3168)

Adds gitFs support to Bitbucket Cloud. It is now mandatory to configure Bitbucket with username/password instead of token.

Closes #2550, Closes #3024
This commit is contained in:
Rhys Arkins 2019-02-04 16:03:02 +01:00 committed by GitHub
parent 1ac01f1d13
commit 7fb7b93ef7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 150 additions and 378 deletions

View file

@ -2,6 +2,7 @@ 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 { appSlug } = require('../../config/app-strings');
let config = {};
@ -11,7 +12,7 @@ module.exports = {
getRepos,
cleanRepo,
initRepo,
getRepoStatus: () => ({}),
getRepoStatus,
getRepoForceRebase,
setBaseBranch,
// Search
@ -76,12 +77,14 @@ async function getRepos(token, endpoint) {
}
// Initialize bitbucket by getting base branch and SHA
async function initRepo({ repository, endpoint }) {
async function initRepo({ repository, endpoint, localDir }) {
logger.debug(`initRepo("${repository}")`);
const opts = hostRules.find({ platform: 'bitbucket' }, { endpoint });
// istanbul ignore next
if (!opts.token) {
throw new Error(`No token found for Bitbucket repository ${repository}`);
if (!(opts.username && opts.password)) {
throw new Error(
`No username/password found for Bitbucket repository ${repository}`
);
}
hostRules.update({ ...opts, platform: 'bitbucket', default: true });
api.reset();
@ -89,6 +92,22 @@ async function initRepo({ repository, endpoint }) {
// 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
@ -126,6 +145,7 @@ async function setBaseBranch(branchName) {
config.baseBranch = branchName;
delete config.baseCommitSHA;
delete config.fileList;
config.storage.setBaseBranch(branchName);
await getFileList(branchName);
}
}
@ -133,76 +153,62 @@ async function setBaseBranch(branchName) {
// Search
// Get full file list
async function getFileList(branchName) {
const branch = branchName || config.baseBranch;
config.fileList = config.fileList || {};
if (config.fileList[branch]) {
return config.fileList[branch];
}
try {
const branchSha = await getBranchCommit(branch);
const filesRaw = await utils.files(
`/2.0/repositories/${config.repository}/src/${branchSha}/`
);
config.fileList[branch] = filesRaw.map(file => file.path);
} catch (err) /* istanbul ignore next */ {
logger.info(
{ repository: config.repository },
'Error retrieving git tree - no files detected'
);
config.fileList[branch] = [];
}
return config.fileList[branch];
function getFileList(branchName) {
return config.storage.getFileList(branchName);
}
// Branch
// Returns true if branch exists, otherwise false
async function branchExists(branchName) {
logger.debug(`branchExists(${branchName})`);
try {
const { name } = (await api.get(
`/2.0/repositories/${config.repository}/refs/branches/${branchName}`
)).body;
return name === branchName;
} catch (err) {
if (err.statusCode === 404) {
return false;
}
// istanbul ignore next
throw err;
}
function branchExists(branchName) {
return config.storage.branchExists(branchName);
}
// TODO rewrite mutating reduce to filter in other adapters
async function getAllRenovateBranches(branchPrefix) {
logger.trace('getAllRenovateBranches');
const allBranches = await utils.accumulateValues(
`/2.0/repositories/${config.repository}/refs/branches`
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);
}
function deleteBranch(branchName) {
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
);
return allBranches
.map(branch => branch.name)
.filter(name => name.startsWith(branchPrefix));
}
// Check if branch's parent SHA = master SHA
async function isBranchStale(branchName) {
logger.debug(`isBranchStale(${branchName})`);
const [branch, baseBranch] = (await Promise.all([
api.get(
`/2.0/repositories/${config.repository}/refs/branches/${branchName}`
),
api.get(
`/2.0/repositories/${config.repository}/refs/branches/${
config.baseBranch
}`
),
])).map(res => res.body);
const branchParentCommit = branch.target.parents[0].hash;
const baseBranchLatestCommit = baseBranch.target.hash;
return branchParentCommit !== baseBranchLatestCommit;
function getCommitMessages() {
return config.storage.getCommitMessages();
}
// Returns the Pull Request for a branch. Null if not exists.
@ -281,29 +287,6 @@ async function setBranchStatus(
);
}
async function deleteBranch(branchName, closePr = false) {
// istanbul ignore if
if (closePr) {
logger.debug('Closing PR');
const pr = await getBranchPr(branchName);
if (pr) {
await api.post(
`/2.0/repositories/${config.repository}/pullrequests/${
pr.number
}/decline`
);
}
}
return api.delete(
`/2.0/repositories/${config.repository}/refs/branches/${branchName}`
);
}
function mergeBranch() {
// The api does not support merging branches, so any automerge must be done via PR
return Promise.reject(new Error('Branch automerge not supported'));
}
async function findOpenIssues(title) {
try {
const currentUser = (await api.get('/2.0/user')).body.username;
@ -396,16 +379,6 @@ async function ensureIssueClosing(title) {
}
}
async function getBranchLastCommitTime(branchName) {
const branches = await utils.accumulateValues(
`/2.0/repositories/${config.repository}/refs/branches`
);
const branch = branches.find(br => br.name === branchName) || {
target: {},
};
return branch.target.date ? new Date(branch.target.date) : new Date();
}
function addAssignees() {
// Bitbucket supports "participants" and "reviewers" so does not seem to have the concept of "assignee"
logger.warn('Cannot add assignees');
@ -603,69 +576,6 @@ async function getBranchCommit(branchName) {
}
}
// Add a new commit, create branch if not existing
async function commitFilesToBranch(
branchName,
files,
message,
parentBranch = config.baseBranch
) {
logger.debug(
`commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`
);
if (branchName !== parentBranch && (await branchExists(branchName))) {
logger.debug('Deleting existing branch');
await deleteBranch(branchName);
delete config.fileList[branchName];
}
const parents = await getBranchCommit(parentBranch);
const form = utils.commitForm({
message,
gitAuthor: config.gitAuthor,
parents,
branchName,
files,
});
await api.post(`/2.0/repositories/${config.repository}/src`, {
json: false,
body: form,
});
}
// Generic File operations
async function getFile(filePath, branchName) {
logger.debug(`getFile(filePath=${filePath}, branchName=${branchName})`);
if (!branchName || branchName === config.baseBranch) {
const fileList = await getFileList(branchName);
if (!fileList.includes(filePath)) {
return null;
}
}
try {
const branchSha = await getBranchCommit(branchName || config.baseBranch);
const file = (await api.get(
`/2.0/repositories/${config.repository}/src/${branchSha}/${filePath}`,
{ json: false }
)).body;
return file;
} catch (err) {
if (err.statusCode === 404) {
return null;
}
throw err;
}
}
async function getCommitMessages() {
logger.debug('getCommitMessages');
const values = await utils.accumulateValues(
`/2.0/repositories/${config.repository}/commits`
);
return values.map(commit => commit.message);
}
// Pull Request
async function getPrList() {
@ -682,6 +592,10 @@ async function getPrList() {
}
function cleanRepo() {
// istanbul ignore if
if (config.storage && config.storage.cleanRepo) {
config.storage.cleanRepo();
}
api.reset();
config = {};
}

View file

@ -1,5 +1,4 @@
const url = require('url');
const FormData = require('form-data');
const api = require('./bb-got-wrapper');
const repoInfoTransformer = repoInfoBody => ({
@ -34,31 +33,6 @@ const addMaxLength = (inputUrl, pagelen = 100) => {
return maxedUrl;
};
const filesEndpoint = async (reqUrl, method = 'get', options) => {
const values = await accumulateValues(reqUrl, method, options);
const commitFolders = values.filter(
value => value.type === 'commit_directory'
);
let commitFiles = values.filter(value => value.type === 'commit_file');
if (
process.env.RENOVATE_DISABLE_FILE_RECURSION !== 'true' &&
commitFolders.length !== 0
) {
const moreFiles = [].concat(
...(await Promise.all(
commitFolders
.map(folder => folder.links.self.href)
.filter(Boolean)
.map(selfUrl => filesEndpoint(selfUrl, method, options))
))
);
commitFiles = [...moreFiles, ...commitFiles];
}
return commitFiles;
};
const accumulateValues = async (reqUrl, method = 'get', options, pagelen) => {
let accumulator = [];
let nextUrl = addMaxLength(reqUrl, pagelen);
@ -87,21 +61,6 @@ const isConflicted = files => {
return false;
};
const commitForm = ({ message, gitAuthor, parents, branchName, files }) => {
const form = new FormData();
form.append('message', message);
// istanbul ignore if
if (gitAuthor) {
form.append('author', gitAuthor);
}
form.append('parents', parents);
form.append('branch', branchName);
files.forEach(({ name, contents }) => {
form.append(`/${name}`, contents);
});
return form;
};
const prInfo = pr => ({
number: pr.id,
body: pr.summary ? pr.summary.raw : undefined,
@ -117,7 +76,5 @@ module.exports = {
buildStates,
prInfo,
accumulateValues,
files: filesEndpoint,
isConflicted,
commitForm,
};

View file

@ -1,33 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`platform/bitbucket commitFilesToBranch() posts files 1`] = `
Array [
"",
"
Content-Disposition: form-data; name=\\"message\\"
message
",
"
Content-Disposition: form-data; name=\\"parents\\"
master_hash
",
"
Content-Disposition: form-data; name=\\"branch\\"
branch
",
"
Content-Disposition: form-data; name=\\"/package.json\\"
hello world
",
"--
",
]
`;
exports[`platform/bitbucket createPr() posts PR 1`] = `
Array [
Array [
@ -62,13 +34,6 @@ Array [
"/2.0/repositories/some/empty/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&pagelen=50",
undefined,
],
Array [
"/2.0/repositories/some/empty/refs/branches/master",
],
Array [
"/2.0/repositories/some/empty/src/null/?pagelen=100",
undefined,
],
Array [
"/2.0/user",
],
@ -165,31 +130,6 @@ Object {
}
`;
exports[`platform/bitbucket getCommitMessages() works 1`] = `
Array [
"Commit messsage 0",
"Commit messsage 1",
"Commit messsage 2",
"Commit messsage 3",
"Commit messsage 4",
"Commit messsage 5",
"Commit messsage 6",
"Commit messsage 7",
"Commit messsage 8",
"Commit messsage 9",
"Commit messsage 10",
"Commit messsage 11",
"Commit messsage 12",
"Commit messsage 13",
"Commit messsage 14",
"Commit messsage 15",
"Commit messsage 16",
"Commit messsage 17",
"Commit messsage 18",
"Commit messsage 19",
]
`;
exports[`platform/bitbucket getPr() exists 1`] = `
Object {
"body": "summary",
@ -236,21 +176,7 @@ Array [
]
`;
exports[`platform/bitbucket setBaseBranch() updates file list 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/refs/branches/branch",
],
Array [
"/2.0/repositories/some/repo/src/branch_hash/?pagelen=100",
undefined,
],
Array [
"/2.0/repositories/some/repo/src/branch_hash/foo_folder/?pagelen=100",
undefined,
],
]
`;
exports[`platform/bitbucket setBaseBranch() updates file list 1`] = `Array []`;
exports[`platform/bitbucket setBranchStatus() posts status 1`] = `
Array [

View file

@ -1,37 +1,44 @@
const URL = require('url');
const responses = require('../../_fixtures/bitbucket/responses');
function streamToString(stream) {
// eslint-disable-next-line promise/avoid-new
return new Promise(resolve => {
const chunks = [];
stream.on('data', chunk => {
chunks.push(chunk.toString());
});
stream.on('end', () => {
resolve(chunks.join(''));
});
stream.resume();
});
}
describe('platform/bitbucket', () => {
let bitbucket;
let api;
let hostRules;
let GitStorage;
beforeEach(() => {
// reset module
jest.resetModules();
jest.mock('../../../lib/platform/bitbucket/bb-got-wrapper');
jest.mock('../../../lib/platform/git/storage');
hostRules = require('../../../lib/util/host-rules');
api = require('../../../lib/platform/bitbucket/bb-got-wrapper');
bitbucket = require('../../../lib/platform/bitbucket');
GitStorage = require('../../../lib/platform/git/storage');
GitStorage.mockImplementation(() => ({
initRepo: jest.fn(),
cleanRepo: jest.fn(),
getFileList: jest.fn(),
branchExists: jest.fn(() => true),
isBranchStale: jest.fn(() => false),
setBaseBranch: jest.fn(),
getBranchLastCommitTime: jest.fn(),
getAllRenovateBranches: jest.fn(),
getCommitMessages: jest.fn(),
getFile: jest.fn(),
commitFilesToBranch: jest.fn(),
mergeBranch: jest.fn(),
deleteBranch: jest.fn(),
getRepoStatus: jest.fn(),
}));
// clean up hostRules
hostRules.clear();
hostRules.update({
platform: 'bitbucket',
token: 'token',
username: 'username',
password: 'password',
});
});
@ -105,55 +112,31 @@ describe('platform/bitbucket', () => {
});
describe('getFileList()', () => {
const getFileList = wrap('getFileList');
it('works', async () => {
it('sends to gitFs', async () => {
await initRepo();
expect(await getFileList('branch')).toEqual([
'foo_folder/foo_file',
'bar_file',
]);
});
it('returns cached result', async () => {
await initRepo();
expect(await getFileList('branch')).toEqual([
'foo_folder/foo_file',
'bar_file',
]);
await mocked(async () => {
await bitbucket.getFileList();
});
});
});
describe('branchExists()', () => {
it('returns true if branch exist in repo', async () => {
api.get.mockImplementationOnce(() => ({ body: { name: 'branch1' } }));
const actual = await bitbucket.branchExists('branch1');
const expected = true;
expect(actual).toBe(expected);
});
it('returns false if branch does not exist in repo', async () => {
api.get.mockImplementationOnce(() => ({ body: { name: 'branch2' } }));
const actual = await bitbucket.branchExists('branch1');
const expected = false;
expect(actual).toBe(expected);
});
it('returns false if 404', async () => {
api.get.mockImplementationOnce(() =>
Promise.reject({
statusCode: 404,
})
);
const actual = await bitbucket.branchExists('branch1');
const expected = false;
expect(actual).toBe(expected);
describe('getFileList()', () => {
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.branchExists();
});
});
});
});
describe('isBranchStale()', () => {
const isBranchStale = wrap('isBranchStale');
it('returns false for same hash', async () => {
it('sends to gitFs', async () => {
await initRepo();
expect(await isBranchStale('branch')).toBe(false);
await mocked(async () => {
await bitbucket.isBranchStale();
});
});
});
@ -207,26 +190,38 @@ describe('platform/bitbucket', () => {
});
describe('getRepoStatus()', () => {
it('exists', async () => {
expect(await bitbucket.getRepoStatus()).toEqual({});
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.getRepoStatus();
});
});
});
describe('deleteBranch()', () => {
it('exists', () => {
expect(bitbucket.deleteBranch).toBeDefined();
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.deleteBranch();
});
});
});
describe('mergeBranch()', () => {
it('throws', async () => {
await expect(bitbucket.mergeBranch()).rejects.toBeDefined();
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.mergeBranch();
});
});
});
describe('getBranchLastCommitTime()', () => {
it('exists', () => {
expect(bitbucket.getBranchLastCommitTime).toBeDefined();
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.getBranchLastCommitTime();
});
});
});
@ -378,67 +373,47 @@ describe('platform/bitbucket', () => {
});
describe('commitFilesToBranch()', () => {
it('posts files', async () => {
it('sends to gitFs', async () => {
await initRepo();
const files = [
{
name: 'package.json',
contents: 'hello world',
},
];
await mocked(async () => {
await bitbucket.commitFilesToBranch('branch', files, 'message');
expect(api.post.mock.calls).toHaveLength(1);
const { body } = api.post.mock.calls[0][1];
const content = (await streamToString(body)).split(
'--' + body.getBoundary()
);
expect(content).toMatchSnapshot();
await bitbucket.commitFilesToBranch();
});
});
});
describe('getFile()', () => {
beforeEach(initRepo);
const getFile = wrap('getFile');
it('works', async () => {
expect(await getFile('bar_file', 'branch')).toBe('bar_file content');
});
it('returns null for file not found', async () => {
expect(await getFile('not_found', 'master')).toBe(null);
});
it('returns null for 404', async () => {
expect(await getFile('not_found', 'branch')).toBe(null);
});
it('throws for non 404', async () => {
await expect(getFile('error', 'branch')).rejects.toBeDefined();
it('sends to gitFs', async () => {
await initRepo();
await mocked(async () => {
await bitbucket.getFile();
});
});
});
describe('getCommitMessages()', () => {
const getCommitMessages = wrap('getCommitMessages');
it('works', async () => {
it('sends to gitFs', async () => {
await initRepo();
expect(await getCommitMessages()).toMatchSnapshot();
await mocked(async () => {
await bitbucket.getCommitMessages();
});
});
});
describe('getAllRenovateBranches()', () => {
const getAllRenovateBranches = wrap('getAllRenovateBranches');
it('retuns filtered branches', async () => {
it('sends to gitFs', async () => {
await initRepo();
expect(await getAllRenovateBranches('renovate/')).toEqual([
'renovate/branch',
'renovate/upgrade',
]);
await mocked(async () => {
await bitbucket.getAllRenovateBranches();
});
});
});
describe('getBranchLastCommitTime()', () => {
const getBranchLastCommitTime = wrap('getBranchLastCommitTime');
it('returns last commit time', async () => {
it('sends to gitFs', async () => {
await initRepo();
expect(await getBranchLastCommitTime('renovate/foo')).toBeDefined();
await mocked(async () => {
await bitbucket.getBranchLastCommitTime();
});
});
});