1 | #!/usr/bin/env node
|
2 |
|
3 | var fs = require('fs');
|
4 | var _ = require('lodash');
|
5 | var http = require('http');
|
6 | var https = require('https');
|
7 | var domain = require('domain');
|
8 | var moment = require('moment');
|
9 | var parser = require('nomnom');
|
10 | var semver = require('semver');
|
11 | var Promise = require("bluebird");
|
12 | var GithubApi = require('github');
|
13 | var linkParser = require('parse-link-header');
|
14 | var ghauth = Promise.promisify(require('ghauth'));
|
15 | var commitStream = require('github-commit-stream');
|
16 |
|
17 |
|
18 | http.globalAgent.maxSockets = 30;
|
19 | https.globalAgent.maxSockets = 30;
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 | opts = 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 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 | .parse()
|
115 | ;
|
116 |
|
117 | if (opts['only-pulls']) opts.merges = true;
|
118 |
|
119 | var commitsBySha = {};
|
120 | var currentDate = moment();
|
121 |
|
122 | var 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 |
|
131 | var token = null;
|
132 |
|
133 |
|
134 | var authOptions = {
|
135 | configName : 'changelog'
|
136 | , note: 'github-changes-1234'
|
137 | , userAgent: 'github-changes-1234'
|
138 | , scopes : ['user', 'public_repo', 'repo']
|
139 | };
|
140 |
|
141 | Promise.promisifyAll(github.repos);
|
142 | Promise.promisifyAll(github.issues);
|
143 | Promise.promisifyAll(github.pullRequests);
|
144 |
|
145 |
|
146 |
|
147 |
|
148 | var 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 |
|
173 | var 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 |
|
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 |
|
232 | var 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 |
|
255 | var getData = function() {
|
256 | if (opts.data === 'commits') return getAllCommits();
|
257 | return getPullRequests();
|
258 | };
|
259 |
|
260 | var 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 |
|
275 | var 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 |
|
295 | output += "\n";
|
296 | });
|
297 | return output.trim();
|
298 | };
|
299 |
|
300 | var getCommitsInMerge = function(mergeCommit) {
|
301 |
|
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;
|
310 | return getAllReachableCommits(parent.sha, store);
|
311 | })
|
312 | };
|
313 |
|
314 | var parentShas = _.pluck(mergeCommit.parents, 'sha');
|
315 | var notSha = parentShas.shift();
|
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 |
|
329 | var 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 |
|
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 |
|
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 |
|
363 |
|
364 |
|
365 | var authors = {};
|
366 | if (isMerge && (opts['only-merges'] || opts['only-pulls'])) {
|
367 | getCommitsInMerge(commit).forEach(function(c){
|
368 |
|
369 |
|
370 |
|
371 |
|
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 |
|
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 {
|
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 |
|
403 | output += "\n";
|
404 | });
|
405 | return output.trim();
|
406 | };
|
407 |
|
408 | var formatter = function(data) {
|
409 | if (opts.data === 'commits') return commitFormatter(data);
|
410 | return prFormatter(data);
|
411 | };
|
412 |
|
413 | var 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 |
|
419 | var auth = function() {
|
420 | if (!token) return;
|
421 | github.authenticate({type: 'oauth', token: token});
|
422 | };
|
423 |
|
424 | var 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 |
|
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 |
|
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 |
|
483 | var done = function (error) {
|
484 | if (!error) process.exit(0);
|
485 | console.log(error);
|
486 | console.log(error.stack);
|
487 | process.exit(1);
|
488 | };
|
489 |
|
490 | var runner = function () {
|
491 | var d = domain.create();
|
492 | d.on('error', done);
|
493 | d.run(task);
|
494 | };
|
495 |
|
496 | runner();
|