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