Merge branch 'hotfix/21.56.9' into master
[gitter.git] / modules / rooms / lib / badger-service.js
blob987a95d66f0c3e6535c9b84ba07f3fc4d5a57d3d
1 'use strict';
3 var env = require('gitter-web-env');
4 var logger = env.logger;
5 var stats = env.stats;
6 var conf = env.config;
7 var format = require('util').format;
8 var github = require('octonode');
9 var _ = require('lodash');
10 var Promise = require('bluebird');
11 var troupeTemplate = require('gitter-web-templates/lib/troupe-template');
13 var templatePromise = troupeTemplate.compile(
14   __dirname + '/../../../public/templates/github-pull-request-body.hbs'
17 var StatusError = require('statuserror');
18 var readmeBadger = require('readme-badger');
19 var path = require('path');
21 function insertBadge(roomUri, content, fileExt, user) {
22   var imageUrl = conf.get('web:badgeBaseUrl') + '/' + roomUri + '.svg';
23   var linkUrl =
24     conf.get('web:basepath') +
25     '/' +
26     roomUri +
27     '?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge';
28   var altText = 'Join the chat at ' + conf.get('web:basepath') + '/' + roomUri;
30   if (!readmeBadger.hasImageSupport(fileExt)) {
31     stats.event('badger.inserted_plaintext', { userId: user.id, fileExt: fileExt });
32   }
34   return readmeBadger.addBadge(content, fileExt, imageUrl, linkUrl, altText);
37 function Client(token) {
38   var client = github.client(token);
40   var self = this;
41   ['get', 'post', 'patch', 'put', 'del'].forEach(function(operation) {
42     self[operation] = function(url, options) {
43       return new Promise(function(resolve, reject) {
44         client[operation](url, options, function(err, status, body) {
45           if (err) return reject(err);
47           if (status >= 400)
48             return reject(new StatusError(status, (body && body.message) || 'HTTP ' + status));
49           resolve(body);
50         });
51       });
52     };
53   });
55 var client = new Client(conf.get('badger:githubToken'));
57 function findReadme(tree, path) {
58   if (!path) path = 'README.md';
60   // Case sensitive first
61   var result = _.find(tree, function(t) {
62     return t.path === path;
63   });
64   if (result) return result;
66   // Case insensitive second
67   return _.find(tree, function(t) {
68     return t.path.toLowerCase() === path.toLowerCase();
69   });
72 function urlForGithubClient(url) {
73   url = url.replace(/^https?:\/\/[A-Za-z0-9\-\.]+/, '');
74   return url;
77 function pullRequestHeadFromBranch(branch) {
78   var branchName = branch.ref.replace(/^.*\//, '');
79   var m = /\/repos\/([\w\-]+)\/.*/.exec(branch.url);
80   if (!m) return;
82   return m[1] + ':' + branchName;
85 function ReadmeUpdater(context) {
86   function findMainBranch() {
87     return client.get(format('/repos/%s', context.sourceRepo), {}).then(function(repo) {
88       if (!context.primaryBranch) {
89         context.primaryBranch = repo.default_branch;
90       }
92       return repo;
93     });
94   }
96   function doForkIfRequired() {
97     // Create a fork
98     return client.post(format('/repos/%s/forks', context.sourceRepo), {}).then(function(fork) {
99       return fork.full_name;
100     });
101   }
103   // GitHub can take some time after a fork to setup the gitrefs
104   // Await the operation completion
105   function getRefsAwait(repo) {
106     var delay = 1000;
107     var url = format('/repos/%s/git/refs/', repo);
108     var start = Date.now();
110     function get() {
111       var timeTaken = (Date.now() - start) / 1000;
112       if (timeTaken > 300 /* 5 minutes */) {
113         return Promise.reject(
114           new Error('Timeout awaiting git data for ' + repo + ' after ' + timeTaken + 's')
115         );
116       }
118       // Exponential backoff
119       delay = Math.floor(delay * 1.1);
121       return client.get(url, {}).then(
122         function(refs) {
123           if (!refs || !Array.isArray(refs) || !refs.length) {
124             return Promise.delay(delay).then(get);
125           }
127           return refs;
128         },
129         function(err) {
130           logger.info('Ignoring failed GitHub request to ' + url + ' failed: ' + err);
131           return Promise.delay(delay).then(get);
132         }
133       );
134     }
136     return get();
137   }
139   function createBranch(repo) {
140     // List the refs
141     return getRefsAwait(repo).then(function(refs) {
142       function findRef(name) {
143         return _.find(refs, function(r) {
144           return r.ref === 'refs/heads/' + name;
145         });
146       }
148       // Find the correct ref
149       var parentBranchRef = findRef(context.primaryBranch);
151       // Not found?
152       if (!parentBranchRef) {
153         throw new Error('Cannot find branch ' + context.primaryBranch);
154       }
156       // Ensure the new branch name is unique
157       var newBranchName;
158       for (var c = 0, found = true; found; c++, found = findRef(newBranchName)) {
159         newBranchName = c ? context.branchPrefix + '-' + c : context.branchPrefix;
160       }
162       var ref = {
163         ref: 'refs/heads/' + newBranchName,
164         sha: parentBranchRef.object.sha
165       };
167       // Create the new branch
168       return client.post(format('/repos/%s/git/refs', repo), ref);
169     });
170   }
172   function generatePRBody() {
173     return templatePromise.then(function(template) {
174       return template(context);
175     });
176   }
178   function createPullRequest(branchRef) {
179     var pullRequestHead = pullRequestHeadFromBranch(branchRef);
181     return generatePRBody().then(function(body) {
182       var badgeOrLink = context.insertedPlaintext ? 'link' : 'badge';
184       var prRequest = {
185         title: 'Add a Gitter chat ' + badgeOrLink + ' to ' + context.readmeFileName,
186         body: body,
187         base: context.primaryBranch,
188         head: pullRequestHead
189       };
191       return client.post(format('/repos/%s/pulls', context.sourceRepo), prRequest);
192     });
193   }
195   function getExistingReadme() {
196     return client.get(format('/repos/%s/readme', context.sourceRepo), {}).catch(function(err) {
197       if (err.status === 404) return null;
198     });
199   }
201   function updateReadme(treeUrl) {
202     return Promise.all([client.get(treeUrl, {}), getExistingReadme()]).spread(function(
203       tree,
204       readme
205     ) {
206       var existingReadme = findReadme(tree.tree, readme && readme.path);
208       if (existingReadme) {
209         var content = new Buffer(readme.content, 'base64').toString('utf8');
210         var fileExt = path.extname(existingReadme.path).substring(1);
212         content = insertBadge(context.roomUri, content, fileExt, context.user);
213         context.insertedPlaintext = !readmeBadger.hasImageSupport(fileExt);
215         context.readmeFileName = existingReadme.path;
216         var readmeNode = {
217           path: existingReadme.path,
218           mode: existingReadme.mode,
219           content: content
220         };
222         return {
223           base_tree: tree.sha,
224           tree: [readmeNode]
225         };
226       }
228       // No readme file exists
229       context.readmeFileName = 'README.md';
230       context.insertedPlaintext = false;
232       var newReadme = {
233         path: 'README.md',
234         mode: '100644',
235         content: '# ' + context.sourceRepo.replace(/^.*\//, '') + '\n' + context.badgeContent
236       };
238       return {
239         base_tree: tree.sha,
240         tree: [newReadme]
241       };
242     });
243   }
245   function prepareTreeForCommit(branchRef) {
246     return client.get(urlForGithubClient(branchRef.object.url), {}).then(function(commit) {
247       var treeUrl = urlForGithubClient(commit.tree.url);
249       return [updateReadme(treeUrl), commit.sha];
250     });
251   }
253   function commitTree(tree, branchRef, latestCommitSha) {
254     // Create a GIT tree
255     return client
256       .post(format('/repos/%s/git/trees', context.destinationRepo), tree)
257       .then(function(tree) {
258         var commitRequest = {
259           message: context.insertedPlaintext ? 'Add Gitter link' : 'Add Gitter badge',
260           author: {
261             name: 'The Gitter Badger',
262             email: 'badger@gitter.im',
263             date: new Date().toISOString().replace(/\.\d+/, '') // GitHub doesn't consider milliseconds as part of ISO8601
264           },
265           parents: [latestCommitSha],
266           tree: tree.sha
267         };
269         // Create a commit for the tree
270         return client.post(format('/repos/%s/git/commits', context.destinationRepo), commitRequest);
271       })
272       .then(function(commit) {
273         var ref = {
274           sha: commit.sha
275         };
277         return client.patch(
278           format('/repos/%s/git/%s', context.destinationRepo, branchRef.ref),
279           ref
280         );
281       });
282   }
284   this.perform = function() {
285     return findMainBranch()
286       .then(doForkIfRequired)
287       .then(function(destinationRepo) {
288         context.destinationRepo = destinationRepo;
290         // Create the branch where we'll do the work
291         return createBranch(destinationRepo);
292       })
293       .then(function(branchRef) {
294         // Prepare a tree for the commit
295         return prepareTreeForCommit(branchRef)
296           .spread(function(treeForCommit, latestCommitSha) {
297             // Commit the tree
298             return commitTree(treeForCommit, branchRef, latestCommitSha);
299           })
300           .then(function() {
301             // Finally, create a pull-request
302             return createPullRequest(branchRef);
303           });
304       })
305       .then(function(pullRequest) {
306         return pullRequest;
307       });
308   };
311 function updateFileAndCreatePullRequest(sourceRepo, roomUri, user, branchPrefix) {
312   return new ReadmeUpdater({
313     token: conf.get('badger:githubToken'),
314     sourceRepo: sourceRepo,
315     roomUri: roomUri,
316     user: user,
317     branchPrefix: branchPrefix,
318     badgeContent: getBadgeMarkdown(roomUri, 'badge'),
319     badgeContentBody: getBadgeMarkdown(roomUri, 'body_badge')
320   }).perform();
323 function getBadgeMarkdown(roomUri, content) {
324   var contentLink = content ? '&utm_content=' + content : '';
326   var imageUrl = conf.get('web:badgeBaseUrl') + '/' + roomUri + '.svg';
327   var linkUrl =
328     conf.get('web:basepath') +
329     '/' +
330     roomUri +
331     '?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge' +
332     contentLink;
334   return '\n[![Gitter](' + imageUrl + ')](' + linkUrl + ')';
337 function sendBadgePullRequest(repo, roomUri, user) {
338   // The name of this stat is due to historical reasons
339   stats.event('badger.clicked', { userId: user.id, repo: repo, roomUri: roomUri });
341   return updateFileAndCreatePullRequest(repo, roomUri, user.username, 'gitter-badge')
342     .then(function(pr) {
343       stats.event('badger.succeeded', { userId: user.id, repo: repo, roomUri: roomUri });
344       return pr;
345     })
346     .catch(function(err) {
347       stats.event('badger.failed', { userId: user.id, repo: repo, roomUri: roomUri });
348       logger.error('Badger failed', { exception: err, uri: repo, roomUri: roomUri });
350       // dont swollow this error, the client needs to be notified of our failure
351       throw err;
352     });
355 exports.sendBadgePullRequest = sendBadgePullRequest;
356 exports.testOnly = {
357   client: client