1 | 'use strict';
|
2 |
|
3 | const Url = require('url');
|
4 | const {Writable, Transform, PassThrough} = require('stream');
|
5 |
|
6 | const request = require('request');
|
7 | const Promise = require('bluebird');
|
8 | const Mime = require('mime');
|
9 |
|
10 | const Tools = require('unifile-common-tools');
|
11 |
|
12 | const NAME = 'github';
|
13 | const SERVICE_HOST = 'github.com';
|
14 | const APP_PERMISSION = 'scope=repo,delete_repo,user';
|
15 |
|
16 | const {UnifileError, BatchError} = require('./error.js');
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 | function getPathTokens(path) {
|
25 | const cleanPath = path.startsWith('/') ? path.substr(1) : path;
|
26 | return cleanPath.split('/').filter((s) => s !== '');
|
27 | }
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 |
|
35 |
|
36 |
|
37 | function paginate(reqOptions, link, memo) {
|
38 | const links = link.split(/,\s*/);
|
39 | let matches;
|
40 | links.some(function(link) {
|
41 | matches = link.trim().match(/<(.+)>;\s*rel="next"/);
|
42 | return matches !== null;
|
43 | });
|
44 |
|
45 | if(!matches) {
|
46 | return Promise.resolve(memo);
|
47 | }
|
48 | return new Promise(function(resolve, reject) {
|
49 | reqOptions.url = matches[1];
|
50 | request(reqOptions, function(err, res, body) {
|
51 | paginate(reqOptions, res.headers.link, memo.concat(JSON.parse(body))).then(resolve);
|
52 | });
|
53 | });
|
54 | }
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 | function move(src, dest, treeRes) {
|
66 | return treeRes.tree.map(function(file) {
|
67 | const regex = new RegExp('^' + src + '$|^' + src + '(/)');
|
68 |
|
69 | return Object.assign({}, file, {path: file.path.replace(regex, dest + '$1')});
|
70 | });
|
71 | }
|
72 |
|
73 |
|
74 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 |
|
80 |
|
81 | function removeFile(path, treeRes, done) {
|
82 | const regex = new RegExp('^' + path + '$|^' + path + '(/)');
|
83 | const filteredTree = treeRes.tree.filter(function(file) {
|
84 | return !regex.test(file.path);
|
85 | });
|
86 | if(filteredTree.length === treeRes.tree.length)
|
87 | done(new UnifileError(UnifileError.ENOENT, 'Not Found'));
|
88 | else done(null, filteredTree);
|
89 | }
|
90 |
|
91 | const createBranch = Symbol('createBranch');
|
92 | const commit = Symbol('commit');
|
93 | const transformTree = Symbol('transformTree');
|
94 | const createBlob = Symbol('createBlob');
|
95 | const assignSessionAccount = Symbol('assignSessionAccount');
|
96 | const commitBlob = Symbol('commitBlob');
|
97 | const callAPI = Symbol('callAPI');
|
98 |
|
99 |
|
100 |
|
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 | class GitHubConnector {
|
108 | |
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 |
|
119 | constructor(config) {
|
120 | if(!config || !config.clientId || !config.clientSecret)
|
121 | throw new Error('Invalid configuration. Please refer to the documentation to get the required fields.');
|
122 | this.clientId = config.clientId;
|
123 | this.clientSecret = config.clientSecret;
|
124 | this.serviceHost = config.serviceHost || SERVICE_HOST;
|
125 | this.oauthCallbackUrl = `https://${this.serviceHost}/login/oauth`;
|
126 | this.redirectUri = config.redirectUri || null;
|
127 |
|
128 | this.infos = Tools.mergeInfos(config.infos, {
|
129 | name: NAME,
|
130 | displayName: 'GitHub',
|
131 | icon: '../assets/github.png',
|
132 | description: 'Edit files from your GitHub repository.'
|
133 | });
|
134 |
|
135 | this.name = this.infos.name;
|
136 | }
|
137 |
|
138 | getInfos(session) {
|
139 | return Object.assign({
|
140 | isLoggedIn: !!(session && ('token' in session) || ('basic' in session)),
|
141 | isOAuth: true,
|
142 | username: (session && session.account) ? session.account.display_name : undefined
|
143 | }, this.infos);
|
144 | }
|
145 |
|
146 | login(session, loginInfos) {
|
147 |
|
148 | if(loginInfos.constructor === String) {
|
149 | const url = Url.parse(loginInfos);
|
150 | if(!url.auth)
|
151 | return Promise.reject(new UnifileError(
|
152 | UnifileError.EACCES,
|
153 | 'Invalid URL. You must provide authentication: http://user:pwd@host'));
|
154 | this.serviceHost = url.host || this.serviceHost;
|
155 | return this.setAccessToken(session, `Basic ${new Buffer(url.auth).toString('base64')}`);
|
156 |
|
157 |
|
158 | } else if('user' in loginInfos && 'password' in loginInfos) {
|
159 | const auth = new Buffer(loginInfos.user + ':' + loginInfos.password).toString('base64');
|
160 | return this.setAccessToken(session, `Basic ${auth}`);
|
161 |
|
162 |
|
163 | } else if('state' in loginInfos && 'code' in loginInfos) {
|
164 | if(loginInfos.state !== session.state)
|
165 | return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid request (cross-site request)'));
|
166 |
|
167 | return new Promise((resolve, reject) => {
|
168 | request({
|
169 | url: this.oauthCallbackUrl + '/access_token',
|
170 | method: 'POST',
|
171 | body: {
|
172 | client_id: this.clientId,
|
173 | client_secret: this.clientSecret,
|
174 | code: loginInfos.code,
|
175 | state: session.state
|
176 | },
|
177 | json: true
|
178 | }, function(err, response, body) {
|
179 | if(err) reject(new UnifileError(UnifileError.EINVAL, 'Error while calling GitHub API. ' + err));
|
180 | else if(response.statusCode >= 400 || 'error' in body)
|
181 | reject(new UnifileError(UnifileError.EACCES, 'Unable to get access token. Please check your credentials.'));
|
182 | else resolve(body.access_token);
|
183 | });
|
184 | })
|
185 | .then((token) => {
|
186 | return this.setAccessToken(session, `token ${token}`);
|
187 | });
|
188 | } else {
|
189 | return Promise.reject(new UnifileError(UnifileError.EACCES, 'Invalid credentials'));
|
190 | }
|
191 | }
|
192 |
|
193 | setAccessToken(session, token) {
|
194 |
|
195 | if(!token.startsWith('token ') && !token.startsWith('Basic '))
|
196 | return Promise.reject(new UnifileError(
|
197 | UnifileError.EACCES,
|
198 | 'Invalid token. It must start with either "token" or "Basic".'));
|
199 |
|
200 | const sessionCopy = Object.assign({}, session);
|
201 | sessionCopy.token = token;
|
202 | return this[callAPI](sessionCopy, '/user', null, 'GET')
|
203 | .then(this[assignSessionAccount].bind(undefined, session))
|
204 | .then(() => {
|
205 | session.token = sessionCopy.token;
|
206 | return session.token;
|
207 | })
|
208 | .catch((e) => {
|
209 |
|
210 | if(e.code === UnifileError.EACCES) throw new UnifileError(401, 'Bad credentials');
|
211 | });
|
212 | }
|
213 |
|
214 | clearAccessToken(session) {
|
215 | Tools.clearSession(session);
|
216 | return Promise.resolve(session);
|
217 | }
|
218 |
|
219 | getAuthorizeURL(session) {
|
220 |
|
221 | session.state = (+new Date() * Math.random()).toString(36).replace('.', '');
|
222 | return Promise.resolve(this.oauthCallbackUrl
|
223 | + '/authorize?' + APP_PERMISSION
|
224 | + '&client_id=' + this.clientId
|
225 | + '&state=' + session.state
|
226 | + (this.redirectUri ? '&redirect_uri=' + this.redirectUri : ''));
|
227 | }
|
228 |
|
229 |
|
230 |
|
231 | readdir(session, path) {
|
232 | const splitPath = getPathTokens(path);
|
233 | let resultPromise;
|
234 | let apiPath;
|
235 | switch (splitPath.length) {
|
236 | case 0:
|
237 | resultPromise = this[callAPI](session, '/user/repos', {affiliation: 'owner'}, 'GET')
|
238 | .then(function(res) {
|
239 | return res.map(function(item) {
|
240 | return {
|
241 | size: item.size,
|
242 | modified: item.updated_at,
|
243 | name: item.name,
|
244 | isDir: true,
|
245 | mime: 'application/git-repo'
|
246 | };
|
247 | });
|
248 | });
|
249 | break;
|
250 | case 1:
|
251 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches';
|
252 | resultPromise = this[callAPI](session, apiPath, null, 'GET')
|
253 | .map((item) => {
|
254 | return this[callAPI](session, Url.parse(item.commit.url).path, null, 'GET')
|
255 | .then(function(result) {
|
256 | return result.commit.author.date;
|
257 | })
|
258 | .then(function(date) {
|
259 | return {
|
260 | size: 'N/A',
|
261 | modified: date,
|
262 | name: item.name,
|
263 | isDir: true,
|
264 | mime: 'application/git-branch'
|
265 | };
|
266 | });
|
267 | });
|
268 | break;
|
269 | default:
|
270 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
271 | const filePath = splitPath.slice(2).join('/');
|
272 | const reqData = {
|
273 | ref: splitPath[1]
|
274 | };
|
275 | resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
|
276 | .map((item) => {
|
277 | return this[callAPI](session, apiPath + '/commits', {path: item.path, sha: splitPath[1]}, 'GET')
|
278 | .then(function(commits) {
|
279 | const isDir = item.type === 'dir';
|
280 | return {
|
281 | size: item.size,
|
282 | modified: commits[0].commit.author.date,
|
283 | name: item.name,
|
284 | isDir: isDir,
|
285 | mime: isDir ? 'application/directory' : Mime.getType(item.name)
|
286 | };
|
287 | });
|
288 | });
|
289 | }
|
290 |
|
291 | return resultPromise;
|
292 | }
|
293 |
|
294 | stat(session, path) {
|
295 | const splitPath = getPathTokens(path);
|
296 | let resultPromise;
|
297 | let apiPath;
|
298 | switch (splitPath.length) {
|
299 | case 0: resultPromise = Promise.reject(new UnifileError(UnifileError.EINVAL, 'You must provide a path to stat'));
|
300 | break;
|
301 | case 1:
|
302 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
303 | resultPromise = this[callAPI](session, apiPath, null, 'GET')
|
304 | .then(function(repo) {
|
305 | return {
|
306 | size: repo.size,
|
307 | modified: repo.updated_at,
|
308 | name: repo.name,
|
309 | isDir: true,
|
310 | mime: 'application/git-repo'
|
311 | };
|
312 | });
|
313 | break;
|
314 | case 2:
|
315 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/branches/' + splitPath[1];
|
316 | resultPromise = this[callAPI](session, apiPath, null, 'GET')
|
317 | .then(function(branch) {
|
318 | return {
|
319 | size: 'N/A',
|
320 | modified: branch.commit.commit.author.date,
|
321 | name: branch.name,
|
322 | isDir: true,
|
323 | mime: 'application/git-branch'
|
324 | };
|
325 | });
|
326 | break;
|
327 | default:
|
328 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
329 | const filePath = splitPath.slice(2).join('/');
|
330 | const reqData = {
|
331 | ref: splitPath[1]
|
332 | };
|
333 | resultPromise = this[callAPI](session, apiPath + '/contents/' + filePath, reqData, 'GET')
|
334 | .then((stat) => {
|
335 | if(Array.isArray(stat)) {
|
336 | return {
|
337 | size: 'N/A',
|
338 | modified: 'N/A',
|
339 | name: filePath.split('/').pop(),
|
340 | isDir: true,
|
341 | mime: 'application/directory'
|
342 | };
|
343 | } else {
|
344 | return this[callAPI](session, apiPath + '/commits', {path: stat.path, sha: splitPath[1]}, 'GET')
|
345 | .then(function(commit) {
|
346 | return {
|
347 | size: stat.size,
|
348 | modified: commit[0].commit.author.date,
|
349 | name: stat.name,
|
350 | isDir: false,
|
351 | mime: Mime.getType(stat.name)
|
352 | };
|
353 | });
|
354 | }
|
355 | });
|
356 | }
|
357 |
|
358 | return resultPromise;
|
359 | }
|
360 |
|
361 | mkdir(session, path) {
|
362 | const splitPath = getPathTokens(path);
|
363 | let reqData = null;
|
364 | let apiPath;
|
365 | switch (splitPath.length) {
|
366 | case 0:
|
367 | return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot create dir with an empty name.'));
|
368 | case 1:
|
369 | apiPath = '/user/repos';
|
370 | reqData = {
|
371 | name: splitPath[0],
|
372 | auto_init: true
|
373 | };
|
374 | return this[callAPI](session, apiPath, reqData, 'POST')
|
375 |
|
376 | .then(() => this.rename(session, path + '/master/README.md', path + '/master/.gitkeep'));
|
377 | case 2:
|
378 | return this[createBranch](session, splitPath[0], splitPath[1]);
|
379 | default:
|
380 | const filePath = splitPath.slice(2).join('/');
|
381 |
|
382 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/contents/' + filePath;
|
383 | reqData = {
|
384 | message: 'Create ' + filePath,
|
385 | content: new Buffer('').toString('base64'),
|
386 | branch: splitPath[1]
|
387 | };
|
388 | return this[callAPI](session, apiPath + '/.gitkeep', reqData, 'PUT')
|
389 | .catch((err) => {
|
390 | if(err.message.startsWith('Invalid request')) throw new Error('Reference already exists');
|
391 | else throw err;
|
392 | });
|
393 | }
|
394 | }
|
395 |
|
396 | writeFile(session, path, data) {
|
397 | const splitPath = getPathTokens(path);
|
398 | if(splitPath.length < 3) {
|
399 | return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'This folder can only contain folders.'));
|
400 | }
|
401 | return this[createBlob](session, splitPath[0], data)
|
402 | .then((blob) => this[commitBlob](session, splitPath, blob));
|
403 | }
|
404 |
|
405 | createWriteStream(session, path) {
|
406 | const splitPath = getPathTokens(path);
|
407 | if(splitPath.length < 3) {
|
408 | const stream = new PassThrough();
|
409 | process.nextTick(() => {
|
410 | stream.emit('error', new UnifileError(UnifileError.ENOTSUP, 'This folder can only contain folders.'));
|
411 | });
|
412 | return stream;
|
413 | }
|
414 |
|
415 | const apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/blobs';
|
416 |
|
417 | const transformer = new Transform({
|
418 | transform(chunk, encoding, callback) {
|
419 | if(this.first) {
|
420 | this.push('{"encoding": "base64", "content": "');
|
421 | this.first = false;
|
422 | }
|
423 | callback(null, chunk.toString('base64'));
|
424 | },
|
425 | flush(callback) {
|
426 | this.push('"}');
|
427 | callback(null);
|
428 | }
|
429 | });
|
430 | transformer.first = true;
|
431 |
|
432 |
|
433 | const stream = this[callAPI](session, apiPath, {}, 'POST', true);
|
434 | transformer.pipe(stream);
|
435 |
|
436 |
|
437 | const chunks = [];
|
438 | const aggregator = new Writable({
|
439 | write(chunk, encoding, callback) {
|
440 | chunks.push(chunk);
|
441 | callback(null);
|
442 | }
|
443 | });
|
444 | stream.pipe(aggregator);
|
445 |
|
446 |
|
447 | aggregator.on('finish', () => {
|
448 | this[commitBlob](session, splitPath, JSON.parse(Buffer.concat(chunks).toString()))
|
449 | .then(() => {
|
450 | transformer.emit('close');
|
451 | });
|
452 | });
|
453 |
|
454 | return transformer;
|
455 | }
|
456 |
|
457 | readFile(session, path, isStream = false) {
|
458 | const splitPath = getPathTokens(path);
|
459 | if(!isStream && splitPath.length < 3) {
|
460 | return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'This folder only contain folders.'));
|
461 | }
|
462 | const apiPath = '/repos/' + session.account.login
|
463 | + '/' + splitPath[0] + '/contents/'
|
464 | + splitPath.slice(2).join('/');
|
465 |
|
466 | var promise = this[callAPI](session, apiPath, {ref: splitPath[1]}, 'GET', isStream);
|
467 | if(isStream) return promise;
|
468 | else {
|
469 | return promise.then(function(res) {
|
470 | if(res.type === 'file') {
|
471 | return Buffer.from(res.content, res.encoding);
|
472 | } else {
|
473 | return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a directory.'));
|
474 | }
|
475 | });
|
476 | }
|
477 | }
|
478 |
|
479 | createReadStream(session, path) {
|
480 | function extract(data, idx, token) {
|
481 | return data.substr(idx + token.length).split('"')[0];
|
482 | }
|
483 |
|
484 | const transformer = new Transform({
|
485 | transform(chunk, encoding, callback) {
|
486 | const data = chunk.toString();
|
487 | if(this.isContent) {
|
488 |
|
489 | callback(null, data.split('"')[0]);
|
490 | } else {
|
491 |
|
492 | let idx;
|
493 | if((idx = data.indexOf(this.contentToken)) > -1) {
|
494 | this.isContent = true;
|
495 |
|
496 | callback(null, Buffer.from(extract(data, idx, this.contentToken), 'base64').toString());
|
497 | } else if((idx = data.indexOf(this.errorToken)) > -1) {
|
498 |
|
499 | this.emit('error', new Error(extract(data, idx, this.errorToken)));
|
500 | } else {
|
501 |
|
502 | callback(null);
|
503 | }
|
504 | }
|
505 | }
|
506 | });
|
507 | transformer.isContent = false;
|
508 | transformer.contentToken = 'content":"';
|
509 | transformer.errorToken = 'message":"';
|
510 | return this.readFile(session, path, true)
|
511 | .pipe(transformer);
|
512 | }
|
513 |
|
514 | rename(session, src, dest) {
|
515 | const splitPath = getPathTokens(src);
|
516 | if(!dest) return Promise.reject(new Error('Cannot rename path with an empty destination'));
|
517 | const splitPathDest = getPathTokens(dest);
|
518 | let apiPath;
|
519 | switch (splitPath.length) {
|
520 | case 0:
|
521 | return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot rename path with an empty name.'));
|
522 | case 1:
|
523 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
524 | const reqData = {name: dest};
|
525 | return this[callAPI](session, apiPath, reqData, 'PATCH');
|
526 | case 2:
|
527 | apiPath = '/repos/' + session.account.login + '/' + splitPath[0] + '/git/refs/heads/';
|
528 | return this[createBranch](session, splitPath[0], splitPathDest[1], splitPath[1])
|
529 | .then(() => {
|
530 | return this[callAPI](session, apiPath + splitPath[1], null, 'DELETE');
|
531 | });
|
532 | default:
|
533 | const fileSrc = splitPath.slice(2).join('/');
|
534 | const fileDest = splitPathDest.slice(2).join('/');
|
535 | return this[transformTree](session, splitPath[0], (tree, done) => done(null, move(fileSrc, fileDest, tree)),
|
536 | 'Move ' + fileSrc + ' to ' + fileDest, splitPath[1]);
|
537 | }
|
538 | }
|
539 |
|
540 | unlink(session, path) {
|
541 | if(!path) return Promise.reject(new UnifileError(UnifileError.EINVAL, 'Cannot remove path with an empty name.'));
|
542 | const splitPath = getPathTokens(path);
|
543 | if(splitPath.length < 3)
|
544 | return Promise.reject(new UnifileError(UnifileError.EISDIR, 'Path is a folder. Use rmdir()'));
|
545 |
|
546 | const filePath = splitPath.slice(2).join('/');
|
547 | return this[transformTree](session, splitPath[0], removeFile.bind(undefined, filePath),
|
548 | 'Remove ' + filePath, splitPath[1]);
|
549 | }
|
550 |
|
551 | rmdir(session, path) {
|
552 | const splitPath = getPathTokens(path);
|
553 | const repoPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
554 | switch (splitPath.length) {
|
555 | case 0:
|
556 | return Promise.reject(new UnifileError(UnifileError.INVAL, 'Cannot remove path with an empty name.'));
|
557 | case 1:
|
558 | return this[callAPI](session, repoPath, null, 'DELETE');
|
559 | case 2:
|
560 | return this[callAPI](session, repoPath + '/branches', null, 'GET')
|
561 | .then((branches) => {
|
562 | if(branches.length > 1)
|
563 | return this[callAPI](session, repoPath + '/git/refs/heads/' + splitPath[1], null, 'DELETE');
|
564 | else {
|
565 | throw new UnifileError(UnifileError.INVAL, 'You cannot leave this folder empty.');
|
566 | }
|
567 | });
|
568 | default:
|
569 | const path = splitPath.slice(2).join('/');
|
570 | return this[transformTree](session, splitPath[0], removeFile.bind(undefined, path),
|
571 | 'Remove ' + path, splitPath[1]);
|
572 | }
|
573 | }
|
574 |
|
575 | batch(session, actions, message) {
|
576 | let actionsChain = Promise.resolve();
|
577 |
|
578 | const actionQueue = actions.slice()
|
579 | .filter((action) => ['rmdir', 'unlink', 'mkdir', 'writefile', 'rename'].indexOf(action.name.toLowerCase()) > -1);
|
580 | while(actionQueue.length > 0) {
|
581 | const action = actionQueue.shift();
|
582 | const splitPath = getPathTokens(action.path);
|
583 | switch (splitPath.length) {
|
584 | case 0: return Promise.reject(new BatchError(
|
585 | UnifileError.EINVAL,
|
586 | 'Cannot execute batch action without a path'));
|
587 | case 1:
|
588 | case 2:
|
589 | const actionName = action.name.toLowerCase();
|
590 | switch (actionName) {
|
591 | case 'rmdir':
|
592 | case 'mkdir':
|
593 | actionsChain = actionsChain.then(() => this[actionName](session, action.path))
|
594 | .catch((err) => err.name !== 'BatchError',
|
595 | (err) => {throw new Error(`Could not complete action ${actionName}: ${err.message}`);});
|
596 | break;
|
597 | case 'writefile':
|
598 | return Promise.reject(new UnifileError(
|
599 | UnifileError.ENOTSUP,
|
600 | `Could not complete action ${actionName}: Cannot create file here.`));
|
601 | case 'rename':
|
602 | if(!action.destination)
|
603 | return Promise.reject(new BatchError(
|
604 | UnifileError.EINVAL,
|
605 | 'Rename actions should have a destination'));
|
606 | actionsChain = actionsChain.then(() => this.rename(session, action.path, action.destination))
|
607 | .catch((err) => err.name !== 'BatchError',
|
608 | (err) => {
|
609 | throw new BatchError(
|
610 | UnifileError.EINVAL,
|
611 | `Could not complete action ${actionName}: ${err.message}`);
|
612 | });
|
613 | break;
|
614 | default:
|
615 | console.warn(`Unsupported batch action on repo/branch: ${actionName}`);
|
616 | }
|
617 | break;
|
618 | default:
|
619 | const fileActions = [action];
|
620 |
|
621 | let sameBranch = true;
|
622 | while(actionQueue.length > 0 && sameBranch) {
|
623 | const nextSplitPath = getPathTokens(actionQueue[0].path);
|
624 | const [lastRepo, lastBranch] = getPathTokens(action.path);
|
625 | sameBranch = nextSplitPath.length > 2 && lastRepo === nextSplitPath[0] && lastBranch === nextSplitPath[1];
|
626 | if(sameBranch) fileActions.push(actionQueue.shift());
|
627 | }
|
628 |
|
629 | actionsChain = actionsChain.then(() => this[transformTree](session, splitPath[0], (treeRes, done) => {
|
630 | const newTrees = {};
|
631 | const blobsWaiting = [];
|
632 |
|
633 | for(const currentAction of fileActions) {
|
634 | const path = getPathTokens(currentAction.path).slice(2).join('/');
|
635 | switch (currentAction.name.toLowerCase()) {
|
636 | case 'unlink':
|
637 | case 'rmdir':
|
638 | treeRes.tree = removeFile(path, treeRes, done);
|
639 | break;
|
640 | case 'rename':
|
641 | if(!currentAction.destination)
|
642 | return new Promise.reject(new UnifileError(
|
643 | UnifileError.EINVAL,
|
644 | 'Rename actions should have a destination'));
|
645 | const src = path;
|
646 | const dest = getPathTokens(currentAction.destination).slice(2).join('/');
|
647 | treeRes.tree = move(src, dest, treeRes);
|
648 | const re = new RegExp(`(^|/)${src}($|/)`);
|
649 | blobsWaiting.forEach((blob) => blob.path = blob.path.replace(re, `$1${dest}$2`));
|
650 | break;
|
651 | case 'mkdir':
|
652 | newTrees[path] = {
|
653 | tree: [],
|
654 | blobs: []
|
655 | };
|
656 | break;
|
657 | case 'writefile':
|
658 | if(!currentAction.content)
|
659 | return new Promise.reject(new UnifileError(
|
660 | UnifileError.EINVAL,
|
661 | 'WriteFile actions should have a content'));
|
662 | if(path.includes('/')) {
|
663 |
|
664 |
|
665 | let closestParent = Object.keys(newTrees).filter((p) => path.includes(p)).sort().pop();
|
666 | if(!closestParent) {
|
667 |
|
668 | closestParent = path.split('/').slice(0, -1).join('/');
|
669 | newTrees[closestParent] = {
|
670 | tree: [],
|
671 | blobs: []
|
672 | };
|
673 | }
|
674 | newTrees[closestParent].blobs.push(this[createBlob](session, splitPath[0], currentAction.content));
|
675 | newTrees[closestParent].tree.push({
|
676 | path: path.replace(closestParent + '/', ''),
|
677 | mode: '100644',
|
678 | type: 'blob'
|
679 | });
|
680 | } else {
|
681 | treeRes.tree = treeRes.tree.filter((node) => node.path !== path);
|
682 | const newNode = {
|
683 | path: path,
|
684 | mode: '100644',
|
685 | type: 'blob'
|
686 | };
|
687 | if(Buffer.isBuffer(currentAction.content)) {
|
688 | blobsWaiting.push({
|
689 | path,
|
690 | blob: this[createBlob](session, splitPath[0], currentAction.content)
|
691 | });
|
692 | } else newNode.content = currentAction.content;
|
693 | treeRes.tree.push(newNode);
|
694 | }
|
695 | break;
|
696 | default:
|
697 | console.warn(`Unsupported batch action: ${currentAction.name}`);
|
698 | }
|
699 | }
|
700 |
|
701 | const treesToPlant = [];
|
702 | Object.keys(newTrees).forEach((path) => {
|
703 | if(newTrees[path].tree.length > 0)
|
704 | treesToPlant.push(path);
|
705 | else treeRes.tree.push({
|
706 | path: path + '/.gitkeep',
|
707 | mode: '100644',
|
708 | type: 'blob',
|
709 | content: ''
|
710 | });
|
711 | });
|
712 | return Promise.all(blobsWaiting.map((b) => b.blob))
|
713 | .then((blobShas) => {
|
714 | blobShas.forEach((sha, idx) => {
|
715 | treeRes.tree
|
716 | .find((n) => n.path === blobsWaiting[idx].path).sha = sha.sha;
|
717 | });
|
718 | return Promise.all(treesToPlant.map((treePath) => {
|
719 | const tree = {tree: newTrees[treePath].tree};
|
720 | const apiPath = '/repos/' + session.account.login + '/' + splitPath[0];
|
721 | return Promise.all(newTrees[treePath].blobs)
|
722 | .then((shas) => {
|
723 | tree.tree.forEach((t, index) => t.sha = shas[index].sha);
|
724 | return this[callAPI](session, apiPath + '/git/trees', tree, 'POST');
|
725 | })
|
726 | .then((t) => {
|
727 | treeRes.tree = treeRes.tree.filter((node) => node.path !== treePath);
|
728 | treeRes.tree.push({
|
729 | path: treePath,
|
730 | type: 'tree',
|
731 | mode: '040000',
|
732 | sha: t.sha
|
733 | });
|
734 | });
|
735 | }))
|
736 | .then(() => {
|
737 | done(null, treeRes.tree);
|
738 | })
|
739 | .catch((e) => {
|
740 | done(new Error(`Could not create a new tree ${e}`));
|
741 | });
|
742 | })
|
743 | .catch((e) => {
|
744 | done(new Error(`Could not create a new blob ${e}`));
|
745 | });
|
746 | }, message || 'Batch update', splitPath[1]))
|
747 | .catch((err) => err.name !== 'BatchError', (err) => {
|
748 | throw new BatchError(UnifileError.EIO, `Error while batch: ${err.message}`);
|
749 | });
|
750 | }
|
751 | }
|
752 | return actionsChain;
|
753 | }
|
754 |
|
755 |
|
756 |
|
757 | |
758 |
|
759 |
|
760 |
|
761 |
|
762 |
|
763 |
|
764 |
|
765 |
|
766 | [createBranch](session, repo, branchName, fromBranch) {
|
767 | const apiPath = '/repos/' + session.account.login + '/' + repo + '/git/refs';
|
768 | return this[callAPI](session, apiPath + '/heads', null, 'GET')
|
769 | .then((res) => {
|
770 | const reqData = {
|
771 | ref: 'refs/heads/' + branchName
|
772 | };
|
773 | if(!fromBranch) reqData.sha = res[0].object.sha;
|
774 | else {
|
775 | const origin = res.filter(function(branch) {
|
776 | return branch.ref === 'refs/heads/' + fromBranch;
|
777 | })[0];
|
778 | if(!origin) throw new Error('Unknown branch origin ' + fromBranch);
|
779 | reqData.sha = origin.object.sha;
|
780 | }
|
781 | return this[callAPI](session, apiPath, reqData, 'POST');
|
782 | });
|
783 | }
|
784 |
|
785 | |
786 |
|
787 |
|
788 |
|
789 |
|
790 |
|
791 |
|
792 |
|
793 |
|
794 |
|
795 |
|
796 |
|
797 |
|
798 |
|
799 |
|
800 |
|
801 |
|
802 | [commit](session, repo, tree, message, branch) {
|
803 | const apiPath = '/repos/' + session.account.login + '/' + repo + '/git';
|
804 | let lastCommitSha;
|
805 |
|
806 |
|
807 | return this[callAPI](session, apiPath + '/refs/heads/' + branch, null, 'GET')
|
808 | .then((res) => {
|
809 | lastCommitSha = res.object.sha;
|
810 |
|
811 | return this[callAPI](session, apiPath + '/commits/' + lastCommitSha, null, 'GET');
|
812 | })
|
813 | .then((res) => {
|
814 | const data = {
|
815 | base_tree: res.tree.sha,
|
816 | tree: tree
|
817 | };
|
818 |
|
819 | return this[callAPI](session, apiPath + '/trees', data, 'POST');
|
820 | })
|
821 | .then((res) => {
|
822 | const data = {
|
823 | parents: [lastCommitSha],
|
824 | tree: res.sha,
|
825 | message: message
|
826 | };
|
827 |
|
828 | return this[callAPI](session, apiPath + '/commits', data, 'POST');
|
829 | })
|
830 | .then((res) => {
|
831 | const data = {
|
832 | sha: res.sha
|
833 | };
|
834 |
|
835 | return this[callAPI](session, apiPath + '/refs/heads/' + branch, data, 'PATCH');
|
836 | });
|
837 | }
|
838 |
|
839 |
|
840 | |
841 |
|
842 |
|
843 |
|
844 |
|
845 |
|
846 |
|
847 |
|
848 |
|
849 |
|
850 |
|
851 |
|
852 | [transformTree](session, repo, transformer, message, branch = 'master') {
|
853 | let lastCommitSha;
|
854 | const apiPath = '/repos/' + session.account.login + '/' + repo;
|
855 | return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, null, 'GET')
|
856 | .then((head) => {
|
857 | lastCommitSha = head.object.sha;
|
858 | return this[callAPI](session, apiPath + '/git/trees/' + head.object.sha, {recursive: 1}, 'GET');
|
859 | })
|
860 | .then((res) => {
|
861 | return new Promise((resolve, reject) => {
|
862 | transformer(res, (err, result) => {
|
863 | if(err && err instanceof Error) reject(err);
|
864 | else resolve(result);
|
865 | });
|
866 | });
|
867 | })
|
868 | .then((tree) => {
|
869 | if(Array.isArray(tree) && tree.length > 0) {
|
870 | return this[callAPI](session, apiPath + '/git/trees', {tree: tree}, 'POST');
|
871 | } else if(Array.isArray(tree)) {
|
872 | return Promise.reject(new UnifileError(UnifileError.ENOTSUP, 'You can not leave this folder empty.'));
|
873 | } else {
|
874 | return Promise.reject(new UnifileError(
|
875 | UnifileError.EIO,
|
876 | 'Invalid tree transformation. Transformer must return an array.'));
|
877 | }
|
878 | })
|
879 | .then((newTree) => {
|
880 | const data = {
|
881 | parents: [lastCommitSha],
|
882 | tree: newTree.sha,
|
883 | message: message
|
884 | };
|
885 | return this[callAPI](session, apiPath + '/git/commits', data, 'POST');
|
886 | })
|
887 | .then((res) => {
|
888 | const data = {
|
889 | sha: res.sha
|
890 | };
|
891 | return this[callAPI](session, apiPath + '/git/refs/heads/' + branch, data, 'PATCH');
|
892 | });
|
893 | }
|
894 |
|
895 | |
896 |
|
897 |
|
898 |
|
899 |
|
900 |
|
901 |
|
902 |
|
903 |
|
904 |
|
905 | [createBlob](session, repoName, content) {
|
906 | const buffer = Buffer.isBuffer(content) ? content : new Buffer(content);
|
907 | const apiPath = '/repos/' + session.account.login + '/' + repoName + '/git/blobs';
|
908 | return this[callAPI](session, apiPath, {
|
909 | content: buffer.toString('base64'),
|
910 | encoding: 'base64'
|
911 | }, 'POST');
|
912 | }
|
913 |
|
914 | |
915 |
|
916 |
|
917 |
|
918 |
|
919 |
|
920 |
|
921 | [assignSessionAccount](session, account) {
|
922 | session.account = {
|
923 | display_name: account.name,
|
924 | login: account.login,
|
925 | num_repos: account.public_repos
|
926 | };
|
927 | }
|
928 |
|
929 | |
930 |
|
931 |
|
932 |
|
933 |
|
934 |
|
935 |
|
936 | [commitBlob](session, splitPath, blob) {
|
937 | const path = splitPath.slice(2).join('/');
|
938 | return this[commit](session, splitPath[0], [{
|
939 | path: path,
|
940 | sha: blob.sha,
|
941 | mode: '100644',
|
942 | type: 'blob'
|
943 | }], 'Create ' + path, splitPath[1]);
|
944 | }
|
945 |
|
946 | |
947 |
|
948 |
|
949 |
|
950 |
|
951 |
|
952 |
|
953 |
|
954 |
|
955 |
|
956 |
|
957 | [callAPI](session, path, data, method, isStream = false, retry = true) {
|
958 | const reqOptions = {
|
959 | url: `https://api.${this.serviceHost}${path}`,
|
960 | method: method,
|
961 | headers: {
|
962 | 'Accept': 'application/vnd.github.v3+json',
|
963 | 'Authorization': session.token,
|
964 | 'User-Agent': 'Unifile',
|
965 | 'X-OAuth-Scopes': 'delete_repo, repo, user'
|
966 | }
|
967 | };
|
968 |
|
969 | if(method === 'GET') reqOptions.qs = data;
|
970 | else if(!isStream) reqOptions.body = JSON.stringify(data);
|
971 |
|
972 | if(isStream) return request(reqOptions);
|
973 | else {
|
974 | return new Promise((resolve, reject) => {
|
975 | request(reqOptions, (err, res, body) => {
|
976 | if(err) {
|
977 | return reject(err);
|
978 | }
|
979 | if(res.statusCode >= 400) {
|
980 | const {code, message} = (() => {
|
981 | const defaultMessage = JSON.parse(body).message;
|
982 | switch (res.statusCode) {
|
983 | case 401:
|
984 | return {code: UnifileError.EACCES, message: 'Bad credentials'};
|
985 | case 403:
|
986 | return {code: UnifileError.EACCES, message: defaultMessage};
|
987 | case 404:
|
988 | return {code: UnifileError.ENOENT, message: 'Not Found'};
|
989 | default:
|
990 | return {code: UnifileError.EIO, message: defaultMessage};
|
991 | }
|
992 | })();
|
993 | return reject(new UnifileError(code, message));
|
994 | }
|
995 | try {
|
996 | const result = res.statusCode !== 204 ? JSON.parse(body) : null;
|
997 | if(res.headers.hasOwnProperty('link')) {
|
998 | paginate(reqOptions, res.headers.link, result).then(resolve);
|
999 | } else resolve(result);
|
1000 | } catch (e) {
|
1001 | reject(e);
|
1002 | }
|
1003 | });
|
1004 | });
|
1005 | }
|
1006 | }
|
1007 | }
|
1008 |
|
1009 | module.exports = GitHubConnector;
|