-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
/
dangerfile.js
342 lines (311 loc) Β· 13.3 KB
/
dangerfile.js
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
/* global danger, fail, warn, message */
// requires
const debug = require('debug')('dangerfile');
const fs = require('fs-extra');
const eslint = require('@seadub/danger-plugin-eslint').default;
const junit = require('@seadub/danger-plugin-junit').default;
const dependencies = require('@seadub/danger-plugin-dependencies').default;
const load = require('@commitlint/load').default;
const lint = require('@commitlint/lint').default;
const packageJSON = require('./package.json');
// Due to bug in danger, we hack env variables in build process.
const ENV = fs.existsSync('./env.json') ? require('./env.json') : process.env;
// constants
const github = danger.github;
// Currently used PR-labels
const Label = {
NEEDS_JIRA: 'needs jira π¨',
NEEDS_TESTS: 'needs tests π¨',
NO_TESTS: 'no tests',
IOS: 'ios',
ANDROID: 'android',
COMMUNITY: 'community π₯',
DOCS: 'docs π',
MERGE_CONFLICTS: 'merge conflicts π¨',
IN_QE_TESTING: 'in-qe-testing π΅'
};
// Sets of existing labels, labels we want to add/exist, labels we want to remove (if they exist)
const existingLabelNames = new Set(github.issue.labels.map(l => l.name));
const labelsToAdd = new Set();
const labelsToRemove = new Set();
async function checkStats(pr) {
// Check if the user deleted more code than added, give a thumbs-up if so
if (pr.deletions > pr.additions) {
message(':thumbsup: Hey!, You deleted more code than you added. That\'s awesome!');
}
// TODO: Check for PRs above a certain threshold of changes and warn?
}
// Check npm test output
async function checkNPMTestOutput() {
const exists = await fs.pathExists('./npm_test.log');
if (!exists) {
return;
}
const npmTestOutput = await fs.readFile('./npm_test.log', 'utf8');
if (npmTestOutput.includes('Test failed. See above for more details.')) {
fail(':disappointed_relieved: `npm test` failed. See below for details.');
message('```' + npmTestOutput + '\n```');
}
}
// Check that the commit messages adhere to our conventions!
async function checkCommitMessages() {
const { rules, parserPreset } = await load();
const allWarnings = await Promise.all(danger.git.commits.map(async commit => {
const report = await lint(commit.message, rules, parserPreset ? { parserOpts: parserPreset.parserOpts } : {});
// Bunch warnings/errors together for same commit!
const errorCount = report.errors.length;
const warningCount = report.warnings.length;
if ((errorCount + warningCount) === 0) {
return [];
}
let msg = `Commit ${danger.utils.href(commit.url, commit.sha)} has a message "${commit.message}" giving `;
if (errorCount > 0) {
msg += `${errorCount} errors`;
if (warningCount > 0) {
msg += ' and ';
}
}
if (warningCount > 0) {
msg += `${warningCount} warnings`;
}
msg += ':\n- ';
if (errorCount > 0) {
msg += report.errors.map(e => e.message).join('\n- ');
}
if (warningCount > 0) {
msg += report.warnings.map(w => w.message).join('\n- ');
}
return [ msg ];
}));
const flattened = [].concat(...allWarnings);
flattened.forEach(w => warn(w)); // propagate warnings/errors about commit conventions
if (flattened.length > 0) {
// at least one bad commit message, better to squash this one
message(':rotating_light: This PR has one or more commits with warnings/errors for commit messages not matching our configuration. You may want to squash merge this PR and edit the message to match our conventions, or ask the original developer to modify their history.');
} else {
// all commits are good, should be good to rebase this one
message(':fist: The commits in this PR match our conventions! Feel free to Rebase and Merge this PR when ready.');
}
}
// Check that if we modify the Android or iOS SDK, we also update the tests
// Also, assign labels based on changes to different dir paths
async function checkChangedFileLocations() {
const android = danger.git.fileMatch('android/**/*.java');
const ios = danger.git.fileMatch('iphone/**/*.h', 'iphone/**/*.m');
const topTiModule = danger.git.fileMatch('iphone/TitaniumKit/**/TopTiModule.m');
// Auto-assign android/ios labels
if (android.edited) {
labelsToAdd.add(Label.ANDROID);
}
if (ios.edited) {
labelsToAdd.add(Label.IOS);
}
// Check if apidoc was modified and apply 'docs' label?
const docs = danger.git.fileMatch('apidoc/**');
if (docs.edited) {
labelsToAdd.add(Label.DOCS);
}
// Mark hasAppChanges if 'common' dir is changed too!
const common = danger.git.fileMatch('common/**');
// TODO: Should we add ios/android labels if common dir is changed?
const hasAppChanges = android.edited || ios.edited || common.edited;
// Check if any tests were changed/added
const tests = danger.git.fileMatch('tests/**/*.js');
const hasNoTestsLabel = existingLabelNames.has(Label.NO_TESTS);
// If we changed android/iOS source, but didn't change tests and didn't use the 'no tests' label
// fail the PR
if (hasAppChanges && !tests.edited && !hasNoTestsLabel) {
labelsToAdd.add(Label.NEEDS_TESTS);
const testDocLink = github.utils.fileLinks([ 'README.md#unit-tests' ]);
fail(`:microscope: There are library changes, but no changes to the unit tests. That's OK as long as you're refactoring existing code, but will require an admin to merge this PR. Please see ${testDocLink} for docs on unit testing.`); // eslint-disable-line max-len
} else {
// If it has the "needs tests" label, remove it
labelsToRemove.add(Label.NEEDS_TESTS);
}
if (topTiModule.edited) {
warn('It looks like you have modified the TopTiModule.m file. Are you sure you meant to do that?');
}
}
// Does the PR have merge conflicts?
async function checkMergeable() {
if (github.pr.mergeable_state === 'dirty') {
labelsToAdd.add(Label.MERGE_CONFLICTS);
} else {
// assume it has no conflicts
labelsToRemove.add(Label.MERGE_CONFLICTS);
}
}
// Check PR author to see if it's community, etc
async function checkCommunity() {
// Don't give special thanks to bot accounts
if (github.pr.user.type === 'Bot') {
return;
}
if (github.pr.author_association === 'FIRST_TIMER') {
labelsToAdd.add(Label.COMMUNITY);
// Thank them profusely! This is their first ever github commit!
message(`:rocket: Wow, ${github.pr.user.login}, your first contribution to GitHub and it's to help us make Titanium better! You rock! :guitar:`);
} else if (github.pr.author_association === 'FIRST_TIME_CONTRIBUTOR') {
labelsToAdd.add(Label.COMMUNITY);
// Thank them, this is their first contribution to this repo!
message(`:confetti_ball: Welcome to the Titanium SDK community, ${github.pr.user.login}! Thank you so much for your PR, you're helping us make Titanium better. :gift:`);
} else if (github.pr.author_association === 'CONTRIBUTOR') {
labelsToAdd.add(Label.COMMUNITY);
// Be nice, this is a community member who has landed PRs before!
message(`:tada: Another contribution from our awesome community member, ${github.pr.user.login}! Thanks again for helping us make Titanium SDK better. :thumbsup:`);
}
}
/**
* Given the `labelsToAdd` Set, add any labels that aren't already on the PR.
*/
async function addMissingLabels() {
const filteredLabels = [ ...labelsToAdd ].filter(l => !existingLabelNames.has(l));
if (filteredLabels.length === 0) {
return;
}
await github.api.issues.addLabels({ owner: github.pr.base.repo.owner.login, repo: github.pr.base.repo.name, issue_number: github.pr.number, labels: filteredLabels });
}
async function requestReviews() {
// someone already started reviewing this PR, move along...
if (github.reviews.length !== 0) {
debug('Already has a review, skipping auto-assignment of requests');
return;
}
// Based on the labels, auto-assign review requests to given teams
const teamsToReview = [];
if (labelsToAdd.has(Label.IOS)) {
teamsToReview.push('ios');
}
if (labelsToAdd.has(Label.ANDROID)) {
teamsToReview.push('android');
}
if (labelsToAdd.has(Label.DOCS)) {
teamsToReview.push('docs');
}
if (teamsToReview.length === 0) {
debug('Does not appear to have changes to iOS, Android or docs. Not auto-assigning reviews to teams');
return;
}
const existingReviewers = github.requested_reviewers.teams;
debug(`Existing review requests for this PR: ${JSON.stringify(existingReviewers)}`);
const teamSlugs = existingReviewers.map(t => t.slug);
// filter to the set of teams not already assigned to review (add only those missing)
const filtered = teamsToReview.filter(t => !teamSlugs.includes(t));
if (filtered.length > 0) {
debug(`Assigning PR reviews to teams: ${filtered}`);
await github.api.pullRequests.createReviewRequest({ owner: github.pr.base.repo.owner.login, repo: github.pr.base.repo.name, pull_number: github.pr.number, team_reviewers: filtered });
}
}
// If a PR has a completed review that is approved, and does not have the in-qe-testing label, add it
async function checkPRisApproved() {
const reviews = github.reviews;
if (reviews.length === 0) {
debug('There are no reviews, skipping auto-assignment check for in-qe-testing label');
return;
}
// What about 'COMMENT' reviews?
const blockers = reviews.filter(r => r.state === 'CHANGES_REQUESTED' || r.state === 'PENDING');
const good = reviews.filter(r => r.state === 'APPROVED' || r.state === 'DISMISSED');
if (good.length > 0 && blockers.length === 0) {
labelsToAdd.add(Label.IN_QE_TESTING);
}
// TODO: Can we also check JIRA ticket and move it to In QE Testing?
}
// TODO: Can we check comments from a QE team member with "FR Passed"?
// Auto assign milestone based on version in package.json
async function updateMilestone() {
const expected_milestone = packageJSON.version;
// If there's a milestone assigned to the PR and it doesn't match up with expected version, emit warning
if (github.pr.milestone && github.pr.milestone.title !== expected_milestone) {
// Typically this is because:
// - The milestone got out of date once we did some branch/version bumping
// - The milestone was set wrong
// - The milestone is for a future version on a maintenance branch (i.e. 8.1.1 on 8_1_X branch where we haven't released 8.1.0 yet)
warn(`This PR has milestone set to ${github.pr.milestone.title}, but the version defined in package.json is ${packageJSON.version}
Please either:
- Update the milestone on the PR
- Update the version in package.json
- Hold the PR to be merged later after a release and version bump on this branch`);
return;
}
const milestones = await github.api.issues.listMilestonesForRepo({ owner: github.pr.base.repo.owner.login, repo: github.pr.base.repo.name });
const milestone_match = milestones.data.find(m => m.title === expected_milestone);
if (!milestone_match) {
debug('Unable to find a Github milestone matching the version in package.json');
return;
}
await github.api.issues.update({ owner: github.pr.base.repo.owner.login, repo: github.pr.base.repo.name, issue_number: github.pr.number, milestone: milestone_match.number });
}
/**
* Removes the set of labels from an issue (if they already existed on it)
*/
async function removeLabels() {
for (const label of labelsToRemove) {
if (existingLabelNames.has(label)) {
await github.api.issues.removeLabel({ owner: github.pr.base.repo.owner.login, repo: github.pr.base.repo.name, number: github.pr.number, name: label });
}
}
}
// Check for iOS crash file
async function checkForIOSCrash() {
const files = await fs.readdir(__dirname);
const crashFiles = files.filter(p => p.startsWith('mocha_') && p.endsWith('.crash'));
if (crashFiles.length > 0) {
const crashLink = danger.utils.href(`${ENV.BUILD_URL}artifact/${crashFiles[0]}`, 'the crash log');
fail(`Test suite crashed on iOS simulator. Please see ${crashLink} for more details.`);
}
}
// Add link to built SDK zipfile!
async function linkToSDK() {
if (ENV.BUILD_STATUS === 'SUCCESS' || ENV.BUILD_STATUS === 'UNSTABLE') {
const sdkLink = danger.utils.href(`${ENV.BUILD_URL}artifact/${ENV.ZIPFILE}`, 'Here\'s the generated SDK zipfile');
message(`:floppy_disk: ${sdkLink}.`);
}
}
// Checks for the expected test reports and reports to the PR if one or more is missing
async function checkForTestRunFailures () {
const expectedJunitFiles = {
'android.emulator.5.0': 'Android 5.0',
'android.emulator.main': 'Android Main',
'cli.report': 'CLI',
'ios.ipad': 'iPad',
'ios.iphone': 'iPhone',
'ios.macos': 'MacOS'
};
const files = await fs.readdir(__dirname);
const junitFiles = files.filter(p => p.startsWith('junit') && p.endsWith('.xml'));
if (junitFiles.length < Object.keys(expectedJunitFiles).length) {
const missing = Object.keys(expectedJunitFiles).filter(name => !junitFiles.includes(`junit.${name}.xml`)).map(name => expectedJunitFiles[name]);
fail(`Test reports missing for ${missing.join(', ')}. This indicates that a build failed or the test app crashed`);
}
}
async function main() {
// do a bunch of things in parallel
// Specifically, anything that collects what labels to add or remove has to be done first before...
await Promise.all([
checkNPMTestOutput(),
checkCommitMessages(),
checkStats(github.pr),
linkToSDK(),
checkForIOSCrash(),
junit({ pathToReport: './junit.*.xml', onlyWarn: true }),
checkChangedFileLocations(),
checkCommunity(),
checkMergeable(),
checkPRisApproved(),
updateMilestone(),
eslint(),
dependencies({ type: 'npm' }),
checkForTestRunFailures()
]);
// ...once we've gathered what labels to add/remove, do that last
await requestReviews();
await removeLabels();
await addMissingLabels();
}
main()
.then(() => process.exit(0))
.catch(err => {
fail(err.toString());
process.exit(1);
});