UNPKG

14.7 kBJavaScriptView Raw
1#!/usr/bin/env node
2
3var fs = require('fs');
4var _ = require('lodash');
5var http = require('http');
6var https = require('https');
7var domain = require('domain');
8var moment = require('moment');
9var parser = require('nomnom');
10var semver = require('semver');
11var Promise = require("bluebird");
12var GithubApi = require('github');
13var linkParser = require('parse-link-header');
14var ghauth = Promise.promisify(require('ghauth'));
15var commitStream = require('github-commit-stream');
16
17// Increase number of concurrent requests
18http.globalAgent.maxSockets = 30;
19https.globalAgent.maxSockets = 30;
20
21// It might be faster to just go through commits on the branch
22// instead of iterating over closed issues, look into this later.
23//
24// Even better yet. I might just be able to do this with git log.
25// tags: git log --tags --simplify-by-decoration --format="%ci%n%d"
26// prs: git log --grep="Merge pull request #" --format="%s%n%ci%n%b"
27
28
29// parse cli options
30opts = parser
31 .scriptName('github-changes')
32 .option('owner', {
33 abbr: 'o'
34 , help: '(required) owner of the Github repository'
35 , required: true
36 })
37 .option('repository', {
38 abbr: 'r'
39 , help: '(required) name of the Github repository'
40 , required: true
41 })
42 .option('data', {
43 abbr: 'd'
44 , help: '(DEPRECATED) use pull requests or commits (choices: pulls, commits)'
45 , choices: ['pulls', 'commits']
46 , default: 'commits'
47 })
48 .option('branch', {
49 abbr: 'b'
50 , help: 'name of the default branch'
51 , default: 'master'
52 })
53 .option('tag-name', {
54 abbr: 'n'
55 , help: 'tag name for upcoming release'
56 , default: 'upcoming'
57 })
58 .option('auth', {
59 abbr: 'a'
60 , help: 'prompt to auth with Github - use this for private repos and higher rate limits'
61 , flag: true
62 })
63 .option('token', {
64 abbr: 'k'
65 , help: 'need to use this or --auth for private repos and higher rate limits'
66 })
67 .option('file', {
68 abbr: 'f'
69 , help: 'name of the file to output the changelog to'
70 , default: 'CHANGELOG.md'
71 })
72 .option('verbose', {
73 abbr: 'v'
74 , help: 'output details'
75 , flag: true
76 })
77 .option('host', {
78 help: 'alternate host name to use with github enterprise'
79 , default: 'api.github.com'
80 })
81 .option('path-prefix', {
82 help: 'path-prefix for use with github enterprise'
83 , default: null
84 })
85 .option('issue-body', {
86 help: '(DEPRECATED) include the body of the issue (--data MUST equal \'pulls\')'
87 , flag: true
88 })
89 .option('no-merges', {
90 help: 'do not include merges'
91 , flag: true
92 })
93 .option('only-merges', {
94 help: 'only include merges'
95 , flag: true
96 })
97 .option('only-pulls', {
98 help: 'only include pull requests'
99 , flag: true
100 })
101 .option('use-commit-body', {
102 help: 'use the commit body of a merge instead of the message - "Merge branch..."'
103 , flag: true
104 })
105 .option('order-semver', {
106 help: 'use semantic versioning for the ordering instead of the tag date'
107 , flag: true
108 })
109 // TODO
110 // .option('template', {
111 // abbr: 't'
112 // , help: '(optional) template to use to generate the changelog'
113 // })
114 .parse()
115;
116
117if (opts['only-pulls']) opts.merges = true;
118
119var commitsBySha = {}; // populated when calling getAllCommits
120var currentDate = moment();
121
122var github = new GithubApi({
123 version: '3.0.0'
124, timeout: 10000
125, protocol: 'https'
126, pathPrefix: opts['path-prefix']
127, host: opts.host
128});
129
130// github auth token
131var token = null;
132
133// ~/.config/changelog.json will store the token
134var authOptions = {
135 configName : 'changelog'
136, note: 'github-changes-1234'
137, userAgent: 'github-changes-1234'
138, scopes : ['user', 'public_repo', 'repo']
139};
140
141Promise.promisifyAll(github.repos);
142Promise.promisifyAll(github.issues);
143Promise.promisifyAll(github.pullRequests);
144
145// TODO: Could probably fetch releases so we don't have to get the commit data
146// for the sha of each tag to figure out the date. Could save alot on api
147// calls.
148var getTags = function(){
149 var tagOpts = {
150 user: opts.owner
151 , repo: opts.repository
152 , per_page: 100
153 };
154 auth();
155 return github.repos.getTagsAsync(tagOpts).map(function(ref){
156 auth();
157 return github.repos.getCommitAsync({
158 user: tagOpts.user
159 , repo: tagOpts.repo
160 , sha: ref.commit.sha
161 }).then(function(commit){
162 opts.verbose && console.log('pulled commit data for tag - ', ref.name);
163 return {
164 name: ref.name
165 , date: moment(commit.commit.committer.date)
166 };
167 });
168 }).then(function(tags){
169 return tags;
170 });
171};
172
173var getPullRequests = function(){
174 var issueOpts = {
175 user: opts.owner
176 , repo: opts.repository
177 , state: 'closed'
178 , sort: 'updated'
179 , direction: 'desc'
180 , per_page: 100
181 , page: 1
182 // , since: null // TODO: this is an improvement to save API calls
183 };
184
185 var getIssues = function(options){
186 auth();
187 return github.issues.repoIssuesAsync(options).then(function(issues){
188 opts.verbose && console.log('issues pulled - ', issues.length);
189 opts.verbose && console.log('issues page - ', options.page);
190 return issues;
191 });
192 };
193
194 return getIssues(issueOpts).then(function(issues){
195 var linkHeader = linkParser(issues.meta.link)
196 var totalPages = (linkHeader && linkHeader.last) ? linkHeader.last.page : 1;
197
198 if (totalPages > issueOpts.page) {
199 var allReqs = [];
200 for(var i=issueOpts.page; i<totalPages; i++){
201 var newOptions = _.clone(issueOpts, true);
202 newOptions.page += i;
203 allReqs.push(getIssues(newOptions));
204 }
205 return Promise.all(allReqs).reduce(function(issues, moreIssues){
206 return issues.concat(moreIssues);
207 }, issues);
208 }
209 return issues;
210 }).map(function(issue){
211 if (!issue.pull_request.html_url) return;
212
213 auth();
214 return github.pullRequests.getAsync({
215 user: issueOpts.user
216 , repo: issueOpts.repo
217 , number: issue.number
218 }).then(function(pr){
219 if (pr.base.ref !== opts.branch) return;
220 if (!pr.merged_at) return;
221 return pr;
222 });
223 }).reduce(function(scrubbed, pr){
224 if (pr) scrubbed.push(pr);
225 return scrubbed;
226 }, [])
227 .then(function(prs){
228 return prs;
229 });
230};
231
232var getAllCommits = function() {
233 opts.verbose && console.log('fetching commits');
234 return new Promise(function(resolve, reject){
235 var commits = [];
236 commitStream({
237 token: token
238 , host: opts.host
239 , pathPrefix: (opts['path-prefix'] == '') ? '' : opts['path-prefix']
240 , user: opts.owner
241 , repo: opts.repository
242 , sha: opts.branch
243 , per_page: 100
244 }).on('data', function(data){
245 commitsBySha[data.sha] = data;
246 commits = commits.concat(data);
247 }).on('end', function(error){
248 if (error) return reject(error);
249 opts.verbose && console.log('fetched all commits');
250 return resolve(commits);
251 });
252 });
253};
254
255var getData = function() {
256 if (opts.data === 'commits') return getAllCommits();
257 return getPullRequests();
258};
259
260var tagger = function(sortedTags, data) {
261 var date = null;
262 if (opts.data === 'commits') date = moment(data.commit.committer.date);
263 else date = moment(data.merged_at);
264
265 var current = null;
266 for (var i=0, len=sortedTags.length; i < len; i++) {
267 var tag = sortedTags[i];
268 if (tag.date < date) break;
269 current = tag;
270 }
271 if (!current) current = {name: opts['tag-name'], date: currentDate};
272 return current;
273};
274
275var prFormatter = function(data) {
276 var currentTagName = '';
277 var output = "## Change Log\n";
278 data.forEach(function(pr){
279 if (pr.tag === null) {
280 currentTagName = opts['tag-name'];
281 output+= "\n### " + opts['tag-name'];
282 output+= "\n";
283 } else if (pr.tag.name != currentTagName) {
284 currentTagName = pr.tag.name;
285 output+= "\n### " + pr.tag.name
286 output+= " (" + pr.tag.date.utc().format("YYYY/MM/DD HH:mm Z") + ")";
287 output+= "\n";
288 }
289
290 output += "- [#" + pr.number + "](" + pr.html_url + ") " + pr.title
291 if (pr.user && pr.user.login) output += " (@" + pr.user.login + ")";
292 if (opts['issue-body'] && pr.body && pr.body.trim()) output += "\n\n >" + pr.body.trim().replace(/\n/ig, "\n > ") +"\n";
293
294 // output += " " + moment(pr.merged_at).utc().format("YYYY/MM/DD HH:mm Z");
295 output += "\n";
296 });
297 return output.trim();
298};
299
300var getCommitsInMerge = function(mergeCommit) {
301 // store reachable commits
302 var store1 = {};
303 var store2 = {};
304
305 var getAllReachableCommits = function(sha, store) {
306 if (!commitsBySha[sha]) return;
307 store[sha]=true;
308 commitsBySha[sha].parents.forEach(function(parent){
309 if (store[parent.sha]) return; // don't revist commits we've explored
310 return getAllReachableCommits(parent.sha, store);
311 })
312 };
313
314 var parentShas = _.pluck(mergeCommit.parents, 'sha');
315 var notSha = parentShas.shift(); // value to pass to --not flag in git log
316 parentShas.forEach(function(sha){
317 return getAllReachableCommits(sha, store1);
318 });
319 getAllReachableCommits(notSha, store2);
320
321 return _.difference(
322 Object.keys(store1)
323 , Object.keys(store2)
324 ).map(function(sha){
325 return commitsBySha[sha];
326 });
327};
328
329var commitFormatter = function(data) {
330 var currentTagName = '';
331 var output = "## Change Log\n";
332 data.forEach(function(commit){
333 var isMerge = (commit.parents.length > 1);
334 var isPull = isMerge && /^Merge pull request #/i.test(commit.commit.message);
335 // exits
336 if ((opts.merges === false) && isMerge) return '';
337 if ((opts['only-merges']) && commit.parents.length < 2) return '';
338 if (
339 (opts['only-pulls'])
340 && !isPull
341 ) return '';
342
343 // choose message content
344 var messages = commit.commit.message.split('\n');
345 var message = messages.shift().trim();
346
347 if (opts['use-commit-body'] && commit.parents.length > 1) {
348 message = messages.join(' ').trim() || message;
349 }
350
351 if (commit.tag === null) {
352 currentTagName = opts['tag-name'];
353 output+= "\n### " + opts['tag-name'];
354 output+= "\n";
355 } else if (commit.tag.name != currentTagName) {
356 currentTagName = commit.tag.name;
357 output+= "\n### " + commit.tag.name
358 output+= " (" + commit.tag.date.utc().format("YYYY/MM/DD HH:mm Z") + ")";
359 output+= "\n";
360 }
361
362 // if commit is a merge then find all commits that belong to the merge
363 // and extract authors out of those. Do this for --only-merges and for
364 // --only-pulls
365 var authors = {};
366 if (isMerge && (opts['only-merges'] || opts['only-pulls'])) {
367 getCommitsInMerge(commit).forEach(function(c){
368 // ignore the author of a merge commit, they might have reviewed,
369 // resolved conflicts, and merged, but I don't think this alone
370 // should result in them being considered one of the authors in
371 // the pull request
372 if (c.parents.length > 1) return;
373
374 if (c.author && c.author.login) {
375 authors[c.author.login] = true;
376 }
377 });
378 }
379 authors = Object.keys(authors);
380
381 // if it's a pull request, then the link should be to the pull request
382 if (isPull) {
383 var prNumber = commit.commit.message.split('#')[1].split(' ')[0];
384 var author = (commit.commit.message.split(/\#\d+\sfrom\s/)[1]||'').split('/')[0];
385 var host = (opts.host === 'api.github.com') ? 'github.com' : opts.host;
386 var url = "https://"+host+"/"+opts.owner+"/"+opts.repository+"/pull/"+prNumber;
387 output += "- [#" + prNumber + "](" + url + ") " + message;
388
389 if (authors.length)
390 output += ' (' + authors.map(function(author){return '@' + author}).join(', ') + ')';
391 else
392 output += " (@" + author + ")";
393 } else { //otherwise link to the commit
394 output += "- [" + commit.sha.substr(0, 7) + "](" + commit.html_url + ") " + message;
395
396 if (authors.length)
397 output += ' (' + authors.map(function(author){return '@' + author}).join(', ') + ')';
398 else if (commit.author && commit.author.login)
399 output += " (@" + commit.author.login + ")";
400 }
401
402 // output += " " + moment(commit.commit.committer.date).utc().format("YYYY/MM/DD HH:mm Z");
403 output += "\n";
404 });
405 return output.trim();
406};
407
408var formatter = function(data) {
409 if (opts.data === 'commits') return commitFormatter(data);
410 return prFormatter(data);
411};
412
413var getGithubToken = function() {
414 if (opts.token) return Promise.resolve({token: opts.token});
415 if (opts.auth) return ghauth(authOptions);
416 return Promise.resolve({});
417};
418
419var auth = function() {
420 if (!token) return;
421 github.authenticate({type: 'oauth', token: token});
422};
423
424var task = function() {
425 getGithubToken()
426 .then(function(authData){
427 if (!authData.token) return;
428 token = authData.token;
429 })
430 .then(function(){
431 return Promise.all([getTags(), getData()])
432 })
433 .spread(function(tags, data){
434 allTags = _.sortBy(tags, 'date').reverse();
435 return data;
436 })
437 .map(function(data){
438 data.tag = tagger(allTags, data);
439 data.tagDate = data.tag.date;
440 return data;
441 })
442 .then(function(data){
443 // order by tag date then commit date DESC
444 if (!opts['order-semver'] && opts.data === 'commits') {
445 data = data.sort(function(a,b){
446 var tagCompare = (a.tagDate - b.tagDate);
447 return (tagCompare) ? tagCompare : (moment(b.commit.committer.date) - moment(a.commit.committer.date));
448 }).reverse();
449 return data;
450 } else if (!opts['order-semver'] && opts.data === 'pulls') {
451 data = data.sort(function(a,b){
452 var tagCompare = (a.tagDate - b.tagDate);
453 return (tagCompare) ? tagCompare : (moment(b.merged_at) - moment(a.merged_at));
454 }).reverse();
455 return data;
456 }
457
458 // order by semver then commit date DESC
459 data = data.sort(function(a,b){
460 var tagCompare = 0;
461 if (a.tag.name === b.tag.name) tagCompare = 0;
462 else if (a.tag.name === opts['tag-name']) tagCompare = 1;
463 else if (b.tag.name === opts['tag-name']) tagCompare -1;
464 else tagCompare = semver.compare(a.tag.name, b.tag.name);
465 return (tagCompare) ? tagCompare : (moment(b.commit.committer.date) - moment(a.commit.committer.date));
466 }).reverse();
467 return data;
468 })
469 .then(function(data){
470 fs.writeFileSync(opts.file, formatter(data));
471 })
472 .then(function(){
473 process.exit(0);
474 })
475 .catch(function(error){
476 console.error('error', error);
477 console.error('stack', error.stack);
478 process.exit(1);
479 })
480 ;
481};
482
483var done = function (error) {
484 if (!error) process.exit(0);
485 console.log(error);
486 console.log(error.stack);
487 process.exit(1);
488};
489
490var runner = function () {
491 var d = domain.create();
492 d.on('error', done);
493 d.run(task);
494};
495
496runner();