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