feat: GitFs for GitLab (#2692)

Since Gitlab does not support using tokens to write to repo, `commitFilesToBranch` will always use the API. This could be changed once GitFS over SSH is implemented. 

Closes #2549
This commit is contained in:
Ayoub Kaanich 2018-10-29 18:25:11 +01:00 committed by Rhys Arkins
parent be65176dc4
commit 975ee2b79b
4 changed files with 468 additions and 253 deletions

View file

@ -1,16 +1,21 @@
const URL = require('url');
const is = require('@sindresorhus/is');
const addrs = require('email-addresses');
const get = require('./gl-got-wrapper');
const hostRules = require('../../util/host-rules');
const GitStorage = require('../git/storage');
const Storage = require('./storage');
let config = {};
let config = {
storage: new Storage(),
};
module.exports = {
getRepos,
cleanRepo,
initRepo,
getRepoStatus: () => ({}),
getRepoStatus,
getRepoForceRebase,
setBaseBranch,
// Search
@ -79,13 +84,25 @@ function urlEscape(str) {
}
function cleanRepo() {
// istanbul ignore if
if (config.storage) {
config.storage.cleanRepo();
}
// In theory most of this isn't necessary. In practice..
get.reset();
config = {};
}
// Initialize GitLab by getting base branch
async function initRepo({ repository, token, oauth, endpoint, gitAuthor }) {
async function initRepo({
repository,
token,
oauth,
endpoint,
gitAuthor,
gitFs,
localDir,
}) {
const opts = hostRules.find(
{ platform: 'gitlab' },
{ token, endpoint, oauth }
@ -97,6 +114,8 @@ async function initRepo({ repository, token, oauth, endpoint, gitAuthor }) {
config = {};
get.reset();
config.repository = urlEscape(repository);
config.gitFs = gitFs;
config.localDir = localDir;
if (gitAuthor) {
try {
config.gitAuthor = addrs.parseOneAddress(gitAuthor);
@ -123,7 +142,27 @@ async function initRepo({ repository, token, oauth, endpoint, gitAuthor }) {
// Discover our user email
config.email = (await get(`user`)).body.email;
delete config.prList;
delete config.fileList;
// istanbul ignore if
if (config.gitFs) {
logger.debug('Enabling Git FS');
let { protocol, host } = URL.parse(opts.endpoint);
host = host || 'gitlab.com';
protocol = protocol || 'https:';
const url = URL.format({
protocol,
auth: 'oauth2:' + (config.forkToken || opts.token),
hostname: host,
pathname: repository + '.git',
});
config.storage = new GitStorage();
await config.storage.initRepo({
...config,
url,
});
} else {
config.storage = new Storage();
await config.storage.initRepo(config);
}
await Promise.all([getPrList(), getFileList()]);
} catch (err) {
logger.debug('Caught initRepo error');
@ -144,89 +183,22 @@ async function setBaseBranch(branchName) {
if (branchName) {
logger.debug(`Setting baseBranch to ${branchName}`);
config.baseBranch = branchName;
delete config.fileList;
await getFileList(branchName);
await config.storage.setBaseBranch(branchName);
}
}
// Search
// Get full file list
async function getFileList(branchName = config.baseBranch) {
if (config.fileList) {
return config.fileList;
}
try {
let url = `projects/${
config.repository
}/repository/tree?ref=${branchName}&per_page=100`;
if (!(process.env.RENOVATE_DISABLE_FILE_RECURSION === 'true')) {
url += '&recursive=true';
}
const res = await get(url, { 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;
function getFileList(branchName = config.baseBranch) {
return config.storage.getFileList(branchName);
}
// 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);
if (res.statusCode === 200) {
logger.debug('Branch exists');
return true;
}
// This probably shouldn't happen
logger.debug("Branch doesn't exist");
return false;
} catch (error) {
if (error.statusCode === 404) {
// If file not found, then return false
logger.debug("Branch doesn't exist");
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;
function branchExists(branchName) {
return config.storage.branchExists(branchName);
}
// Returns the Pull Request for a branch. Null if not exists.
@ -252,6 +224,79 @@ async function getBranchPr(branchName) {
return getPr(pr.iid);
}
function getAllRenovateBranches(branchPrefix) {
return config.storage.getAllRenovateBranches(branchPrefix);
}
function isBranchStale(branchName) {
return config.storage.isBranchStale(branchName);
}
async function commitFilesToBranch(
branchName,
files,
message,
parentBranch = config.baseBranch
) {
// GitLab does not support push with GitFs
let storage = config.storage;
// istanbul ignore if
if (config.gitFs) {
storage = new Storage();
storage.initRepo(config);
}
const res = await storage.commitFilesToBranch(
branchName,
files,
message,
parentBranch
);
// 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);
}
return res;
}
function getFile(filePath, branchName) {
return config.storage.getFile(filePath, branchName);
}
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',
},
}
);
}
}
return config.storage.deleteBranch(branchName);
}
function mergeBranch(branchName) {
return config.storage.mergeBranch(branchName);
}
function getBranchLastCommitTime(branchName) {
return config.storage.getBranchLastCommitTime(branchName);
}
// istanbul ignore next
function getRepoStatus() {
return config.storage.getRepoStatus();
}
// Returns the combined status for a branch.
async function getBranchStatus(branchName, requiredStatusChecks) {
logger.debug(`getBranchStatus(${branchName})`);
@ -264,17 +309,13 @@ async function getBranchStatus(branchName, requiredStatusChecks) {
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;
// First, get the branch commit SHA
const branchSha = await config.storage.getBranchCommit(branchName);
// Now, check the statuses for that commit
url = `projects/${
const url = `projects/${
config.repository
}/repository/commits/${branchSha}/statuses`;
res = await get(url);
const 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
@ -298,17 +339,13 @@ async function getBranchStatus(branchName, requiredStatusChecks) {
}
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;
// First, get the branch commit SHA
const branchSha = await config.storage.getBranchCommit(branchName);
// Now, check the statuses for that commit
url = `projects/${
const url = `projects/${
config.repository
}/repository/commits/${branchSha}/statuses`;
res = await get(url);
const res = await get(url);
logger.debug(`Got res with ${res.body.length} results`);
for (const check of res.body) {
if (check.name === context) {
@ -325,14 +362,10 @@ async function setBranchStatus(
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;
// First, get the branch commit SHA
const branchSha = await config.storage.getBranchCommit(branchName);
// Now, check the statuses for that commit
url = `projects/${config.repository}/statuses/${branchSha}`;
const url = `projects/${config.repository}/statuses/${branchSha}`;
const options = {
state,
description,
@ -349,60 +382,6 @@ async function setBranchStatus(
}
}
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)}`
);
}
async function mergeBranch(branchName) {
logger.debug(`mergeBranch(${branchName}`);
const branchURI = encodeURIComponent(branchName);
try {
await get.post(
`projects/${
config.repository
}/repository/commits/${branchURI}/cherry_pick?branch=${config.baseBranch}`
);
} catch (err) {
logger.info({ err }, `Error pushing branch merge for ${branchName}`);
throw new Error('Branch automerge failed');
}
// Update base commit
config.baseCommitSHA = null;
// Delete branch
await deleteBranch(branchName);
}
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();
}
}
// Issue
async function getIssueList() {
@ -780,106 +759,8 @@ function getPrBody(input) {
.replace(/\]\(\.\.\/pull\//g, '](../merge_requests/');
}
// 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;
}
}
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();
} 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
) {
logger.debug(
`commitFilesToBranch('${branchName}', files, message, '${parentBranch})'`
);
const opts = {
body: {
branch: branchName,
commit_message: message,
start_branch: parentBranch,
actions: [],
},
};
// istanbul ignore if
if (config.gitAuthor) {
opts.body.author_name = config.gitAuthor.name;
opts.body.author_email = config.gitAuthor.address;
}
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);
}
let res = 'created';
try {
if (await branchExists(branchName)) {
logger.debug('Deleting existing branch');
await deleteBranch(branchName);
res = 'updated';
}
} 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);
}
return res;
}
// GET /projects/:id/repository/commits
async function getCommitMessages() {
logger.debug('getCommitMessages');
const res = await get(`projects/${config.repository}/repository/commits`);
return res.body.map(commit => commit.title);
}
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;
function getCommitMessages() {
return config.storage.getCommitMessages();
}
function getVulnerabilityAlerts() {

View file

@ -0,0 +1,303 @@
const get = require('./gl-got-wrapper');
function urlEscape(str) {
return str ? str.replace(/\//g, '%2F') : str;
}
class Storage {
constructor() {
// config
let config = {};
// cache
let baseCommitSHA = null;
let branchFiles = {};
Object.assign(this, {
initRepo,
cleanRepo,
getRepoStatus: () => ({}),
branchExists,
commitFilesToBranch,
createBranch,
deleteBranch,
getAllRenovateBranches,
getBranchCommit,
getBranchLastCommitTime,
getCommitMessages,
getFile,
getFileList,
isBranchStale,
mergeBranch,
setBaseBranch,
});
function initRepo(args) {
cleanRepo();
config = { ...args };
}
function cleanRepo() {
baseCommitSHA = null;
branchFiles = {};
}
// 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);
if (res.statusCode === 200) {
logger.debug('Branch exists');
return true;
}
// This probably shouldn't happen
logger.debug("Branch doesn't exist");
return false;
} catch (error) {
if (error.statusCode === 404) {
// If file not found, then return false
logger.debug("Branch doesn't exist");
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 baseSHA = await getBaseCommitSHA();
logger.debug(`baseSHA=${baseSHA}`);
// Return true if the SHAs don't match
return parentSha !== baseSHA;
}
// 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})'`
);
const opts = {
body: {
branch: branchName,
commit_message: message,
start_branch: parentBranch,
actions: [],
},
};
// istanbul ignore if
if (config.gitAuthor) {
opts.body.author_name = config.gitAuthor.name;
opts.body.author_email = config.gitAuthor.address;
}
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);
}
let res = 'created';
try {
if (await branchExists(branchName)) {
logger.debug('Deleting existing branch');
await deleteBranch(branchName);
res = 'updated';
}
} catch (err) {
// istanbul ignore next
logger.info(`Ignoring branch deletion failure`);
}
logger.debug('Adding commits');
await get.post(`projects/${config.repository}/repository/commits`, opts);
return res;
}
async function createBranch(branchName, sha) {
await get.post(
`projects/${config.repository}/repository/branches?branch=${urlEscape(
branchName
)}&ref=${sha}`
);
}
async function deleteBranch(branchName) {
await get.delete(
`projects/${config.repository}/repository/branches/${urlEscape(
branchName
)}`
);
}
// Search
// Get full file list
async function getFileList(branchName = config.baseBranch) {
if (branchFiles[branchName]) {
return branchFiles[branchName];
}
try {
let url = `projects/${
config.repository
}/repository/tree?ref=${branchName}&per_page=100`;
if (!(process.env.RENOVATE_DISABLE_FILE_RECURSION === 'true')) {
url += '&recursive=true';
}
const res = await get(url, { paginate: true });
branchFiles[branchName] = res.body
.filter(item => item.type === 'blob' && item.mode !== '120000')
.map(item => item.path)
.sort();
logger.debug(
`Retrieved fileList with length ${branchFiles[branchName].length}`
);
} catch (err) {
logger.info('Error retrieving git tree - no files detected');
branchFiles[branchName] = [];
}
return branchFiles[branchName];
}
// Generic File operations
async function getFile(filePath, branchName) {
logger.debug(`getFile(filePath=${filePath}, branchName=${branchName})`);
if (!branchName || branchName === config.baseBranch) {
if (
branchFiles[branchName] &&
!branchFiles[branchName].includes(filePath)
) {
return null;
}
}
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();
} 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;
}
}
// GET /projects/:id/repository/commits
async function getCommitMessages() {
logger.debug('getCommitMessages');
const res = await get(`projects/${config.repository}/repository/commits`);
return res.body.map(commit => commit.title);
}
function getBranchDetails(branchName) {
const url = `/projects/${
config.repository
}/repository/branches/${urlEscape(branchName)}`;
return get(url);
}
async function getBaseCommitSHA() {
if (!baseCommitSHA) {
const branchDetails = await getBranchDetails(config.baseBranch);
baseCommitSHA = branchDetails.body.commit.id;
}
return baseCommitSHA;
}
async function mergeBranch(branchName) {
logger.debug(`mergeBranch(${branchName}`);
const branchURI = encodeURIComponent(branchName);
try {
await get.post(
`projects/${
config.repository
}/repository/commits/${branchURI}/cherry_pick?branch=${
config.baseBranch
}`
);
} catch (err) {
logger.info({ err }, `Error pushing branch merge for ${branchName}`);
throw new Error('Branch automerge failed');
}
// Update base commit
baseCommitSHA = null;
// Delete branch
await deleteBranch(branchName);
}
async function getBranchCommit(branchName) {
const branchUrl = `projects/${
config.repository
}/repository/branches/${urlEscape(branchName)}`;
try {
const branch = (await get(branchUrl)).body;
if (branch && branch.commit) {
return branch.commit.id;
}
} catch (err) {
// istanbul ignore next
logger.error({ err }, `getBranchCommit error`);
}
// istanbul ignore next
return null;
}
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();
}
}
async function setBaseBranch(branchName) {
if (branchName) {
logger.debug(`Setting baseBranch to ${branchName}`);
config.baseBranch = branchName;
branchFiles = {};
await getFileList(branchName);
}
}
}
}
module.exports = Storage;

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`platform/gitlab/storage createBranch() creates the branch 1`] = `
Array [
Array [
"projects/undefined/repository/branches?branch=renovate%2Fsome-branch&ref=commit",
],
]
`;

View file

@ -0,0 +1,22 @@
describe('platform/gitlab/storage', () => {
jest.mock('../../../lib/platform/gitlab/gl-got-wrapper');
const GitlabStorage = require('../../../lib/platform/gitlab/storage');
const GitStorage = require('../../../lib/platform/git/storage');
const get = require('../../../lib/platform/gitlab/gl-got-wrapper');
it('has same API for git storage', () => {
const gitlabMethods = Object.keys(new GitlabStorage()).sort();
const gitMethods = Object.keys(new GitStorage()).sort();
expect(gitlabMethods).toMatchObject(gitMethods);
});
it('getRepoStatus exists', async () => {
expect((await new GitlabStorage()).getRepoStatus()).toEqual({});
});
describe('createBranch()', () => {
it('creates the branch', async () => {
get.post.mockReturnValue({});
const storage = new GitlabStorage();
await storage.createBranch('renovate/some-branch', 'commit');
expect(get.post.mock.calls).toMatchSnapshot();
});
});
});