unifile-github.js

'use strict';

const Url = require('url');
const {Writable, Transform, PassThrough} = require('stream');

const request = require('request');
const Promise = require('bluebird');
const Mime = require('mime');

const Tools = require('unifile-common-tools');

const NAME = 'github';
const SERVICE_HOST = 'github.com';
const DEFAULT_APP_PERMISSION = 'scope=repo,delete_repo,user';

const {UnifileError, BatchError} = require('./error.js');

/*
 * Remove first '/', split the path and remove empty tokens
 * @param {String} path - Path to split
 * @return {Array<String>} an array with path levels as elements
 * @private
 */
function getPathTokens(path) {
	const cleanPath = path.startsWith('/') ? path.substr(1) : path;
	return cleanPath.split('/').filter((s) => s !== '');
}

/**
 * Handle GitHub pagination
 * @param  {Object} reqOptions - Options to pass to the request. Url will be overidden
 * @param  {string} link - Link header
 * @param  {Object[]} memo - Aggregator of result
 * @return {Promise} a Promise of aggregated result
 * @private
 */
function paginate(reqOptions, link, memo) {
	const links = link.split(/,\s*/);
	let matches;
	links.some(function(link) {
		matches = link.trim().match(/<(.+)>;\s*rel="next"/);
		return matches !== null;
	});
	// End of pagination
	if(!matches) {
		return Promise.resolve(memo);
	}
	return new Promise(function(resolve, reject) {
		reqOptions.url = matches[1];
		request(reqOptions, function(err, res, body) {
			const commit = JSON.parse(body);
			paginate(reqOptions, res.headers.link, memo.concat(commit))
			.then(resolve);
		});
	});
}


/**
 * Move a folder or a file in a branch by transforming the given tree
 * @param  {string} src - Source path relative to branch root
 * @param  {string} dest - Destination path relative to branch root
 * @param  {Object} treeRes[].tree - Commit tree
 * @param  {string} [branch=main] - Branch containing file/folder
 * @private
 */
function move(src, dest, treeRes) {
	return treeRes.tree.map(function(file) {
		const regex = new RegExp('^' + src + '$|^' + src + '(/)');
		// Overrides file path
		return Object.assign({}, file, {path: file.path.replace(regex, dest + '$1')});
	});
}

/**
 * Remove a file/folder by transforming the given tree
 * @param  {string} path - Path to the file/folder to delete
 * @param  {Object} treeRes - Result of a GET request to the tree API
 * @param  {Object} treeRes[].tree - Commit tree
 * @param  {string} [branch=main] - Branch containing file/folder
 * @private
 */
function removeFile(path, treeRes, done) {
	const regex = new RegExp('^' + path + '$|^' + path + '(/)');
	const filteredTree = treeRes.tree.filter(function(file) {
		return !regex.test(file.path);
	});
	if(filteredTree.length === treeRes.tree.length)
		done(new UnifileError(UnifileError.ENOENT, 'Not Found'));
	else done(null, filteredTree);
}

const createBranch = Symbol('createBranch');
const commit = Symbol('commit');
const transformTree = Symbol('transformTree');
const createBlob = Symbol('createBlob');
const assignSessionAccount = Symbol('assignSessionAccount');
const commitBlob = Symbol('commitBlob');
const callAPI = Symbol('callAPI');

/**
 * Service connector for {@link https://github.com|GitHub} plateform.
 *
 * This will need a registered GitHub application with valid redirection for your server.
 * You can register a new application {@link https://github.com/settings/applications/new|here} and
 * learn more about GitHub OAuth Web application flow
 * {@link https://developer.github.com/v3/oauth/#web-application-flow|here}
 */
class GitHubConnector {
	/**
	 * @constructor
	 * @param {Object} config - Configuration object
	 * @param {string} config.clientId - GitHub application client ID
	 * @param {string} config.clientSecret - GitHub application client secret
	 * @param {string} [config.redirectUri] - GitHub application redirect URI.
	 *																				You still need to register it in your GitHub App
	 * @param {string} [config.name=github] - Name of the connector
	 * @param {string} [config.serviceHost=github.com] - Hostname of the service
	 * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
	 */
	constructor(config) {
		if(!config || !config.clientId || !config.clientSecret)
			throw new Error('Invalid configuration. Please refer to the documentation to get the required fields.');
		this.clientId = config.clientId;
		this.clientSecret = config.clientSecret;
		this.serviceHost = config.serviceHost || SERVICE_HOST;
		this.oauthCallbackUrl = `https://${this.serviceHost}/login/oauth`;
		this.redirectUri = config.redirectUri || null;
		this.permission = config.permission || DEFAULT_APP_PERMISSION;

		this.infos = Tools.mergeInfos(config.infos, {
			name: NAME,
			displayName: 'GitHub',
			icon: '../assets/github.png',
			description: 'Edit files from your GitHub repository.'
		});

		this.name = this.infos.name;
	}

	getInfos(session) {
		return Object.assign({
			isLoggedIn: !!(session && ('token' in session) || ('basic' in session)),
			isOAuth: true,
			username: (session && session.account) ? session.account.display_name : undefined
		}, this.infos);
	}

	login(session, loginInfos) {
		// Authenticated URL
		if(loginInfos.constructor === String) {
			const url = Url.parse(loginInfos);
			if(!url.auth)
				return Promise.reject(new UnifileError(
					UnifileError.EACCES,
					'Invalid URL. You must provide authentication: http://user:pwd@host'));
			this.serviceHost = url.host || this.serviceHost;
			return this.setAccessToken(session, `Basic ${new Buffer(url.auth).toString('base64')}`);

			// Basic auth
		} else if('user' in loginInfos && 'password' in loginInfos) {
			const auth = new Buffer(loginInfos.user + ':' + loginInfos.password).toString('base64');
			return this.setAccessToken(session, `Basic ${auth}`);

			// OAuth
		} else if('state' in loginInfos && 'code' in loginInfos) {
			if(loginInfos.state !== session.state)
				return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid request (cross-site request)'));

			return new Promise((resolve, reject) => {
				request({
					url: this.oauthCallbackUrl + '/access_token',
					method: 'POST',
					body: {
						client_id: this.clientId,
						client_secret: this.clientSecret,
						code: loginInfos.code,
						state: session.state
					},
					json: true
				}, function(err, response, body) {
					if(err) reject(new UnifileError(UnifileError.EINVAL, 'Error while calling GitHub API. ' + err));
					else if(response.statusCode >= 400 || 'error' in body)
						reject(new UnifileError(UnifileError.EACCES, 'Unable to get access token. Please check your credentials.'));
					else resolve(body);
				});
			})
			.then(({access_token, scope, token_type}) => {
				return this.setAccessToken(session, `token ${access_token}`)
				.then((token) => {
					session.scope = scope;
					session.token_type = token_type;
					return token;
				});
			});
		} else {
			return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid credentials'));
		}
	}

	setAccessToken(session, token) {
		// Check if token is a valid OAuth or Basic token
		if(!token.startsWith('token ') && !token.startsWith('Basic '))
			return Promise.reject(new UnifileError(
				UnifileError.EACCES,
				'Invalid token. It must start with either "token" or "Basic".'));
		// Create a copy to only set the true token when we know it's the good one
		const sessionCopy = Object.assign({}, session);
		sessionCopy.token = token;
		return this[callAPI](sessionCopy, '/user', null, 'GET')
		.then(this[assignSessionAccount].bind(undefined, session))
		.then(() => {
			session.token = sessionCopy.token;
			return session.token;
		})
		.catch((e) => {
			// Override default error message ('Requires authentication')
			if(e.code === UnifileError.EACCES) throw new UnifileError(401, 'Bad credentials');
		});
	}

	clearAccessToken(session) {
		Tools.clearSession(session);
		return Promise.resolve(session);
	}

	getAuthorizeURL(session) {
		// Generate a random string for the state
		session.state = (+new Date() * Math.random()).toString(36).replace('.', '');
		return Promise.resolve(this.oauthCallbackUrl
			+ '/authorize?' + this.permission
			+ '&client_id=' + this.clientId
			+ '&state=' + session.state
			+ (this.redirectUri ? '&redirect_uri=' + this.redirectUri : ''));
	}

	//Filesystem commands

	readdir(session, path) {
		const splitPath = getPathTokens(path);
		let resultPromise;
		let apiPath;
		switch (splitPath.length) {
			case 0: // List repos
				resultPromise = this[callAPI](session, '/user/repos', {affiliation: 'owner'}, 'GET')
				.then(function(res) {
					return res.map(function(item) {
						return {
							size: item.size,
							modified: item.updated_at,
							name: item.name,
							isDir: true,
							mime: 'application/git-repo'
						};
					});
				});
				break;
			case 1: // List all branches
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches';
				resultPromise = this[callAPI](session, apiPath, null, 'GET')
				.map((item) => {
					return {
						size: 'N/A',
						modified: null,
						name: item.name,
						isDir: true,
						mime: 'application/git-branch'
					};
				});
				break;
			default: // List files of one branch
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
				const filePath = splitPath.slice(2).join('/');
				const reqData = {
					ref: splitPath[1]
				};
				resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
				.map((item) => {
					return this[callAPI](session, apiPath + '/commits', {path: item.path, sha: splitPath[1]}, 'GET')
					.then(function(commits) {
						const isDir = item.type === 'dir';
						return {
							size: item.size,
							modified: commits[0].commit.author.date,
							name: item.name,
							isDir: isDir,
							mime: isDir ? 'application/directory' : Mime.getType(item.name)
						};
					});
				});
		}

		return resultPromise;
	}

	stat(session, path) {
		const splitPath = getPathTokens(path);
		let resultPromise;
		let apiPath;
		switch (splitPath.length) {
			case 0: resultPromise = Promise.reject(new UnifileError(UnifileError.EINVAL, 'You must provide a path to stat'));
				break;
			case 1: // Get repo stat
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
				resultPromise = this[callAPI](session, apiPath, null, 'GET')
				.then(function(repo) {
					return {
						size: repo.size,
						modified: repo.updated_at,
						name: repo.name,
						isDir: true,
						mime: 'application/git-repo'
					};
				});
				break;
			case 2: // Get branch stat
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches/' + splitPath[1];
				resultPromise = this[callAPI](session, apiPath, null, 'GET')
				.then(function(branch) {
					return {
						size: 'N/A',
						modified: branch.commit.commit.author.date,
						name: branch.name,
						isDir: true,
						mime: 'application/git-branch'
					};
				});
				break;
			default: // Get a content stat
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
				const filePath = splitPath.slice(2).join('/');
				const reqData = {
					ref: splitPath[1]
				};
				resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
				.then((stat) => {
					if(Array.isArray(stat)) {
						return {
							size: 'N/A',
							modified: 'N/A',
							name: filePath.split('/').pop(),
							isDir: true,
							mime: 'application/directory'
						};
					} else {
						return this[callAPI](session, apiPath + '/commits', {path: stat.path, sha: splitPath[1]}, 'GET')
						.then(function(commit) {
							return {
								size: stat.size,
								modified: commit[0].commit.author.date,
								name: stat.name,
								isDir: false,
								mime: Mime.getType(stat.name)
							};
						});
					}
				});
		}

		return resultPromise;
	}

	mkdir(session, path) {
		const splitPath = getPathTokens(path);
		let reqData = null;
		let apiPath;
		switch (splitPath.length) {
			case 0: // Error
				return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot create dir with an empty name.'));
			case 1: // Create a repo
				apiPath = '/user/repos';
				reqData = {
					name: splitPath[0],
					auto_init: true
				};
				return this[callAPI](session, apiPath, reqData, 'POST')
				// Renames default README to a more discreet .gitkeep
				.then(() => this.rename(session, path + '/main/README.md', path + '/main/.gitkeep'));
			case 2: // Create a branch
				return this[createBranch](session, splitPath[0], splitPath[1]);
			default: // Create a folder (with a .gitkeep file in it because git doesn't track empty folder)
				const filePath = splitPath.slice(2).join('/');

				apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/contents/' + filePath;
				reqData = {
					message: 'Create ' + filePath,
					content: new Buffer('').toString('base64'),
					branch: splitPath[1]
				};
				return this[callAPI](session, apiPath + '/.gitkeep', reqData, 'PUT')
				.catch((err) => {
					if(err.message.startsWith('Invalid request')) throw new Error('Reference already exists');
					else throw err;
				});
		}
	}

	writeFile(session, path, data) {
		const splitPath = getPathTokens(path);
		if(splitPath.length < 3) {
			return Promise.reject(new UnifileError(UnifileError.ENOTSUP, `
				You are trying to add a file to a folder of Github which does not support files.
				You can not add a file here because Github allows only folders at this level.
				Please create a folder and put your file in it.
				Github requires a folder to have at least 2 parents in order to accept files.
			`));
		}
		return this[createBlob](session, splitPath[0], data)
		.then((blob) => this[commitBlob](session, splitPath, blob));
	}

	createWriteStream(session, path) {
		const splitPath = getPathTokens(path);
		if(splitPath.length < 3) {
			const stream = new PassThrough();
			process.nextTick(() => {
				stream.emit('error', new UnifileError(UnifileError.ENOTSUP, `
					You are trying to add a file to a folder of Github which does not support files.
					You can not add a file here because Github allows only folders at this level.
					Please create a folder and put your file in it.
					Github requires a folder to have at least 2 parents in order to accept files.
				`));
			});
			return stream;
		}

		const apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/blobs';
		// This will encapsulate the raw content into an acceptable Blob request
		const transformer = new Transform({
			transform(chunk, encoding, callback) {
				if(this.first) {
					this.push('{"encoding": "base64", "content": "');
					this.first = false;
				}
				callback(null, chunk.toString('base64'));
			},
			flush(callback) {
				this.push('"}');
				callback(null);
			}
		});
		transformer.first = true;

		// Make the request and pipe the transformer as input
		const stream = this[callAPI](session, apiPath, {}, 'POST', true);
		transformer.pipe(stream);

		// Catch Blob request response
		const chunks = [];
		const aggregator = new Writable({
			write(chunk, encoding, callback) {
				chunks.push(chunk);
				callback(null);
			}
		});
		stream.pipe(aggregator);

		// Now commit the blob with the full response
		aggregator.on('finish', () => {
			this[commitBlob](session, splitPath, JSON.parse(Buffer.concat(chunks).toString()))
			.then(() => {
				transformer.emit('close');
			});
		});

		return transformer;
	}

	readFile(session, path, isStream = false) {
		const splitPath = getPathTokens(path);
		if(!isStream && splitPath.length < 3) {
			return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'This folder only contain folders.'));
		}
		const apiPath = '/repos/' + session.account.login
			+ '/' + splitPath[0] + '/contents/'
			+ splitPath.slice(2).join('/');

		var promise = this[callAPI](session, apiPath, {ref: splitPath[1]}, 'GET', isStream);
		if(isStream) return promise;
		else {
			return promise.then(function(res) {
				if(res.type === 'file') {
					return Buffer.from(res.content, res.encoding);
				} else {
					return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a directory.'));
				}
			});
		}
	}

	createReadStream(session, path) {
		function extract(data, idx, token) {
			return data.substr(idx + token.length).split('"')[0];
		}

		const transformer = new Transform({
			transform(chunk, encoding, callback) {
				const data = chunk.toString();
				if(this.isContent) {
					// return all the content until a " shows up
					callback(null, data.split('"')[0]);
				} else {
					// TODO better start detection
					let idx;
					if((idx = data.indexOf(this.contentToken)) > -1) {
						this.isContent = true;
						// Content detected, returns it until "
						callback(null, Buffer.from(extract(data, idx, this.contentToken), 'base64').toString());
					} else if((idx = data.indexOf(this.errorToken)) > -1) {
						// Request errored
						this.emit('error', new Error(extract(data, idx, this.errorToken)));
					} else {
						// Drop content
						callback(null);
					}
				}
			}
		});
		transformer.isContent = false;
		transformer.contentToken = 'content":"';
		transformer.errorToken = 'message":"';
		return this.readFile(session, path, true)
		.pipe(transformer);
	}

	rename(session, src, dest) {
		const splitPath = getPathTokens(src);
		if(!dest) return Promise.reject(new Error('Cannot rename path with an empty destination'));
		const splitPathDest = getPathTokens(dest);
		let apiPath;
		switch (splitPath.length) {
			case 0: // Error
				return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty name.'));
			case 1: // Rename repo
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
				const reqData = {name: dest};
				return this[callAPI](session, apiPath, reqData, 'PATCH');
			case 2: // Rename branch (actually copy src to dest then remove src)
				apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/refs/heads/';
				return this[createBranch](session, splitPath[0], splitPathDest[1], splitPath[1])
				.then(() => {
					return this[callAPI](session, apiPath + splitPath[1], null, 'DELETE');
				});
			default: // Rename a file/folder
				const fileSrc = splitPath.slice(2).join('/');
				const fileDest = splitPathDest.slice(2).join('/');
				return this[transformTree](session, splitPath[0], (tree, done) => done(null, move(fileSrc, fileDest, tree)),
					'Move ' + fileSrc + ' to ' + fileDest, splitPath[1]);
		}
	}

	unlink(session, path) {
		if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot remove path with an empty name.'));
		const splitPath = getPathTokens(path);
		if(splitPath.length < 3)
			return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a folder. Use rmdir()'));

		const filePath = splitPath.slice(2).join('/');
		return this[transformTree](session, splitPath[0], removeFile.bind(undefined, filePath),
			'Remove ' + filePath, splitPath[1]);
	}

	rmdir(session, path) {
		const splitPath = getPathTokens(path);
		const repoPath = '/repos/' + session.account.login + '/' + splitPath[0];
		switch (splitPath.length) {
			case 0: // Error
				return Promise.reject(new UnifileError(UnifileError.INVAL, 'Cannot remove path with an empty name.'));
			case 1: // Remove repo
				return this[callAPI](session, repoPath, null, 'DELETE');
			case 2: // Remove branch
				return this[callAPI](session, repoPath + '/branches', null, 'GET')
				.then((branches) => {
					if(branches.length > 1)
						return this[callAPI](session, repoPath + '/git/refs/heads/' + splitPath[1], null, 'DELETE');
					else {
						throw new UnifileError(UnifileError.INVAL, 'You cannot leave this folder empty.');
					}
				});
			default: // Remove file/folder
				const path = splitPath.slice(2).join('/');
				return this[transformTree](session, splitPath[0], removeFile.bind(undefined, path),
					'Remove ' + path, splitPath[1]);
		}
	}

	batch(session, actions, message) {
		let actionsChain = Promise.resolve();
		// Filter invalid batch actions
		const actionQueue = actions.slice()
		.filter((action) => ['rmdir', 'unlink', 'mkdir', 'writefile', 'rename'].indexOf(action.name.toLowerCase()) > -1);
		while(actionQueue.length > 0) {
			const action = actionQueue.shift();
			const splitPath = getPathTokens(action.path);
			switch (splitPath.length) {
				case 0: return Promise.reject(new BatchError(
					UnifileError.EINVAL,
					'Cannot execute batch action without a path'));
				case 1:
				case 2:
					const actionName = action.name.toLowerCase();
					switch (actionName) {
						case 'rmdir':
						case 'mkdir':
							actionsChain = actionsChain.then(() => this[actionName](session, action.path))
							.catch((err) => err.name !== 'BatchError',
								(err) => {throw new Error(`Could not complete action ${actionName}: ${err.message}`);});
							break;
						case 'writefile':
							return Promise.reject(new UnifileError(
								UnifileError.ENOTSUP,
								`Could not complete action ${actionName}: Cannot create file here.`));
						case 'rename':
							if(!action.destination)
								return Promise.reject(new BatchError(
									UnifileError.EINVAL,
									'Rename actions should have a destination'));
							actionsChain = actionsChain.then(() => this.rename(session, action.path, action.destination))
							.catch((err) => err.name !== 'BatchError',
								(err) => {
									throw new BatchError(
										UnifileError.EINVAL,
										`Could not complete action ${actionName}: ${err.message}`);
								});
							break;
						default:
							console.warn(`Unsupported batch action on repo/branch: ${actionName}`);
					}
					break;
				default:
					const fileActions = [action];
					// Get all the file action on this branch to group in a commit
					let sameBranch = true;
					while(actionQueue.length > 0 && sameBranch) {
						const nextSplitPath = getPathTokens(actionQueue[0].path);
						const [lastRepo, lastBranch] = getPathTokens(action.path);
						sameBranch = nextSplitPath.length > 2 && lastRepo === nextSplitPath[0] && lastBranch === nextSplitPath[1];
						if(sameBranch) fileActions.push(actionQueue.shift());
					}

					actionsChain = actionsChain.then(() => this[transformTree](session, splitPath[0], (treeRes, done) => {
						const newTrees = {};
						const blobsWaiting = [];

						for(const currentAction of fileActions) {
							const path = getPathTokens(currentAction.path).slice(2).join('/');
							switch (currentAction.name.toLowerCase()) {
								case 'unlink':
								case 'rmdir':
									treeRes.tree = removeFile(path, treeRes, done);
									break;
								case 'rename':
									if(!currentAction.destination)
										return new Promise.reject(new UnifileError(
											UnifileError.EINVAL,
											'Rename actions should have a destination'));
									const src = path;
									const dest = getPathTokens(currentAction.destination).slice(2).join('/');
									treeRes.tree = move(src, dest, treeRes);
									const re = new RegExp(`(^|/)${src}($|/)`);
									blobsWaiting.forEach((blob) => blob.path = blob.path.replace(re, `$1${dest}$2`));
									break;
								case 'mkdir':
									newTrees[path] = {
										tree: [],
										blobs: []
									};
									break;
								case 'writefile':
									if(!currentAction.content)
										return new Promise.reject(new UnifileError(
											UnifileError.EINVAL,
											'WriteFile actions should have a content'));
									if(path.includes('/')) {
										// We'll need a subtree
										// Check existing ones with the longest path matching this file parent
										let closestParent = Object.keys(newTrees).filter((p) => path.includes(p)).sort().pop();
										if(!closestParent) {
											// If none, create one
											closestParent = path.split('/').slice(0, -1).join('/');
											newTrees[closestParent] = {
												tree: [],
												blobs: []
											};
										}
										newTrees[closestParent].blobs.push(this[createBlob](session, splitPath[0], currentAction.content));
										newTrees[closestParent].tree.push({
											path: path.replace(closestParent + '/', ''),
											mode: '100644',
											type: 'blob'
										});
									} else {
										treeRes.tree = treeRes.tree.filter((node) => node.path !== path);
										const newNode = {
											path: path,
											mode: '100644',
											type: 'blob'
										};
										if(Buffer.isBuffer(currentAction.content)) {
											blobsWaiting.push({
												path,
												blob: this[createBlob](session, splitPath[0], currentAction.content)
											});
										} else newNode.content = currentAction.content;
										treeRes.tree.push(newNode);
									}
									break;
								default:
									console.warn(`Unsupported batch action: ${currentAction.name}`);
							}
						}

						const treesToPlant = [];
						Object.keys(newTrees).forEach((path) => {
							if(newTrees[path].tree.length > 0)
								treesToPlant.push(path);
							else treeRes.tree.push({
								path: path + '/.gitkeep',
								mode: '100644',
								type: 'blob',
								content: ''
							});
						});
						return Promise.all(blobsWaiting.map((b) => b.blob))
						.then((blobShas) => {
							blobShas.forEach((sha, idx) => {
								treeRes.tree
								.find((n) => n.path === blobsWaiting[idx].path).sha = sha.sha;
							});
							return Promise.all(treesToPlant.map((treePath) => {
								const tree = {tree: newTrees[treePath].tree};
								const apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
								return Promise.all(newTrees[treePath].blobs)
								.then((shas) => {
									tree.tree.forEach((t, index) => t.sha = shas[index].sha);
									return this[callAPI](session, apiPath + '/git/trees', tree, 'POST');
								})
								.then((t) => {
									treeRes.tree = treeRes.tree.filter((node) => node.path !== treePath);
									treeRes.tree.push({
										path: treePath,
										type: 'tree',
										mode: '040000',
										sha: t.sha
									});
								});
							}))
							.then(() => {
								done(null, treeRes.tree);
							})
							.catch((e) => {
								done(new Error(`Could not create a new tree ${e}`));
							});
						})
						.catch((e) => {
							done(new Error(`Could not create a new blob ${e}`));
						});
					}, message || 'Batch update', splitPath[1]))
					.catch((err) => err.name !== 'BatchError', (err) => {
						throw new BatchError(UnifileError.EIO, `Error while batch: ${err.message}`);
					});
			}
		}
		return actionsChain;
	}

	// Internals

	/**
	 * Create a branch with the given parameters
	 * @param {GHSession} session - GH session
	 * @param  {string} repo - Repository name where to create the branch
	 * @param  {string} branchName - Name for the newly created branch
	 * @param  {string} [fromBranch] - Branch to start the new branch from. Default to the default_branch of the repo
	 * @return {Promise} a Promise of the API call result
	 * @private
	 */
	[createBranch](session, repo, branchName, fromBranch) {
		const apiPath = '/repos/' + session.account.login + '/' + repo + '/git/refs';
		return this[callAPI](session, apiPath + '/heads', null, 'GET')
		.then((res) => {
			const reqData = {
				ref: 'refs/heads/' + branchName
			};
			if(!fromBranch) reqData.sha = res[0].object.sha;
			else {
				const origin = res.filter(function(branch) {
					return branch.ref === 'refs/heads/' + fromBranch;
				})[0];
				if(!origin) throw new Error('Unknown branch origin ' + fromBranch);
				reqData.sha = origin.object.sha;
			}
			return this[callAPI](session, apiPath, reqData, 'POST');
		});
	}

	/**
	 * Create and push a commit
	 * @param {GHSession} session - GH session
	 * @param {string} repo - Name of the repository to commit
	 * @param {Object[]} tree - Array of objects to commit
	 * @param {string} tree[].path - Full path to the file to modify
	 * @param {string} tree[].mode - Object mode (100644 for files)
	 * @param {string} tree[].type - Object type (blob/commit/tree)
	 * @param {string} [tree[].content] - Content to put into file. If set, sha will be ignored
	 * @param {string} [tree[].sha] - Sha of the object to put in the tree. Will be ignored if content is set
	 * @param {string} message - Message of the commit
	 * @param  {string} [branch=main] - Branch containing the tree
	 * @return {Promise} a Promise of the server response
	 *
	 * @see {@link https://developer.github.com/v3/git/trees/#create-a-tree|Create a tree}
	 * @private
	 * */
	[commit](session, repo, tree, message, branch) {
		const apiPath = '/repos/' + session.account.login + '/' + repo + '/git';
		let lastCommitSha;

		// Get branch head
		return this[callAPI](session, apiPath + '/refs/heads/' + branch, null, 'GET')
		.then((res) => {
			lastCommitSha = res.object.sha;
			// Get last commit info
			return this[callAPI](session, apiPath + '/commits/' + lastCommitSha, null, 'GET');
		})
		.then((res) => {
			const data = {
				base_tree: res.tree.sha,
				tree: tree
			};
				// Create a new tree
			return this[callAPI](session, apiPath + '/trees', data, 'POST');
		})
		.then((res) => {
			const data = {
				parents: [lastCommitSha],
				tree: res.sha,
				message: message
			};
				// Create a new commit with the new tree
			return this[callAPI](session, apiPath + '/commits', data, 'POST');
		})
		.then((res) => {
			const data = {
				sha: res.sha
			};
				// Update head
			return this[callAPI](session, apiPath + '/refs/heads/' + branch, data, 'PATCH');
		});
	}


	/**
	 * Transform the git tree and commit the transformed tree
	 * @param {GHSession} session - GH session
	 * @param {string} repo - Name of the repository to commit
	 * @param  {Function} transformer - Function to apply on tree. Get the tree as first param and wait for an array in the callback.
	 * @param  {string} message - Commit message for the new tree
	 * @param  {string} [branch=main] - Branch containing the tree
	 * @return {Promise} a Promise of the server response
	 *
	 * @see {@link https://developer.github.com/v3/git/trees/#create-a-tree|Create a tree}
	 * @private
	 */
	[transformTree](session, repo, transformer, message, branch = 'main') {
		let lastCommitSha;
		const apiPath = '/repos/' + session.account.login + '/' + repo;
		return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, null, 'GET')
		.then((head) => {
			lastCommitSha = head.object.sha;
			return this[callAPI](session, apiPath + '/git/trees/' + head.object.sha, {recursive: 1}, 'GET');
		})
		.then((res) => {
			return new Promise((resolve, reject) => {
				transformer(res, (err, result) => {
					if(err && err instanceof Error) reject(err);
					else resolve(result);
				});
			});
		})
		.then((tree) => {
			if(Array.isArray(tree) && tree.length > 0) {
				return this[callAPI](session, apiPath + '/git/trees', {tree: tree}, 'POST');
			} else if(Array.isArray(tree)) {
				return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'You can not leave this folder empty.'));
			} else {
				return Promise.reject(new UnifileError(
					UnifileError.EIO,
					'Invalid tree transformation. Transformer must return an array.'));
			}
		})
		.then((newTree) => {
			const data = {
				parents: [lastCommitSha],
				tree: newTree.sha,
				message: message
			};
			return this[callAPI](session, apiPath + '/git/commits', data, 'POST');
		})
		.then((res) => {
			const data = {
				sha: res.sha
			};
			return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, data, 'PATCH');
		});
	}

	/**
	 * Create a blob in the designated repository
	 * @param {Object} session - GitHub session storage
	 * @param {string} repoName - Name of the repository where to create the blob
	 * @param {string|Buffer} content - Content of the blob
	 * @return {Promise} a promise of result for the blob creation
	 *
	 * @see {@link https://developer.github.com/v3/git/blobs/#create-a-blob|Create a blob}
	 * @private
	 */
	[createBlob](session, repoName, content) {
		const buffer = Buffer.isBuffer(content) ? content : new Buffer(content);
		const apiPath = '/repos/' + session.account.login + '/' + repoName + '/git/blobs';
		return this[callAPI](session, apiPath, {
			content: buffer.toString('base64'),
			encoding: 'base64'
		}, 'POST');
	}

	/**
	 * Fetch the account information on the service and map them to the session
	 * @param {Object} session - GH session
	 * @param {Object} account - GH account
	 * @return {Promise<null>} an empty promise
	 * @private
	 */
	[assignSessionAccount](session, account) {
		session.account = {
			display_name: account.name,
			login: account.login,
			num_repos: account.public_repos
		};
	}

	/**
	 * Commit a blob to the given repo, branch and path
	 * @param {Object} session - GH session
	 * @param {string[]} splitPath - Path tokens containing repo/branch/path
	 * @param {Object} blob - Blob return by the blob creation route
	 * @private
	 */
	[commitBlob](session, splitPath, blob) {
		const path = splitPath.slice(2).join('/');
		return this[commit](session, splitPath[0], [{
			path: path,
			sha: blob.sha,
			mode: '100644',
			type: 'blob'
		}], 'Create ' + path, splitPath[1]);
	}

	/**
	 * Make a call to the GitHub API
	 * @param {Object} session - GitHub session storage
	 * @param {string} path - End point path
	 * @param {Object} data - Data to pass. Convert to querystring if method is GET or to the request body
	 * @param {string} method - HTTP verb to use
	 * @param {boolean} isStream - Access the API as a stream or not
	 * @param {boolean} retry - Allow the request to retry on error
	 * @return {Promise|Stream} a Promise of the result send by server or a stream to the endpoint
	 * @private
	 */
	[callAPI](session, path, data, method, isStream = false, retry = true) {
		const reqOptions = {
			url: `https://api.${this.serviceHost}${encodeURI(path)}`,
			method: method,
			headers: {
				'Accept': 'application/vnd.github.v3+json',
				'Authorization': session.token,
				'User-Agent': 'Unifile',
				'X-OAuth-Scopes': 'delete_repo, repo, user'
			}
		};

		if(method === 'GET') reqOptions.qs = data;
		else if(!isStream) reqOptions.body = JSON.stringify(data);

		if(isStream) return request(reqOptions);
		else {
			return new Promise((resolve, reject) => {
				request(reqOptions, (err, res, body) => {
					if(err) {
						return reject(err);
					}
					if(res.statusCode >= 400) {
						const {code, message} = (() => {
							const defaultMessage = JSON.parse(body).message;
							switch (res.statusCode) {
								case 401:
									return {code: UnifileError.EACCES, message: 'Bad credentials'};
								case 403:
									return {code: UnifileError.EACCES, message: defaultMessage};
								case 404:
									return {code: UnifileError.ENOENT, message: 'Not Found'};
								default:
									return {code: UnifileError.EIO, message: defaultMessage};
							}
						})();
						return reject(new UnifileError(code, message));
					}
					try {
						const result = res.statusCode !== 204 ? JSON.parse(body) : null;
						if(res.headers.hasOwnProperty('link')) {
							paginate(reqOptions, res.headers.link, result).then(resolve);
						} else resolve(result);
					} catch (e) {
						reject(e);
					}
				});
			});
		}
	}
}

module.exports = GitHubConnector;