-
Notifications
You must be signed in to change notification settings - Fork 5.1k
/
dangerfile.ts
406 lines (341 loc) · 15.3 KB
/
dangerfile.ts
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
import { danger, fail, markdown, message, warn } from 'danger'
import * as fs from 'fs'
import { dirname } from 'path'
function getIndicesOf(searchStr: string, str: string): number[] {
var searchStrLen = searchStr.length
if (searchStrLen == 0) {
return []
}
var startIndex = 0,
index,
indices: number[] = []
while ((index = str.indexOf(searchStr, startIndex)) > -1) {
indices.push(index)
startIndex = index + searchStrLen
}
return indices
}
async function getLinesAddedByFile(files: string[], { exclude = [] }: { exclude?: string[] } = {}) {
return await Promise.all(
files.flatMap(async (file) => {
if (exclude.some((name) => file.endsWith(name))) {
return []
}
const structuredDiff = await danger.git.structuredDiffForFile(file)
return (structuredDiff?.chunks || []).flatMap((chunk) => {
return chunk.changes.filter((change) => change.type === 'add')
})
}),
)
}
async function checkLockedDependencies() {
const packageJSONFiles = danger.git.modified_files
.concat(danger.git.created_files)
.filter((file) => file.includes('package.json'))
const allLinesAdded = (await getLinesAddedByFile(packageJSONFiles)).flatMap((x) => x)
allLinesAdded.forEach((change) => {
const stringChange = change.content
if (stringChange.includes(': "^') || stringChange.includes(': "*') || stringChange.includes(': "~')) {
fail(
`Detected a non-locked dependency at \`${stringChange}\`. Please lock all dependency versions for security purposes!`,
)
}
})
}
function checkGeneralizedHookFiles() {
const touchedFiles = danger.git.modified_files.concat(danger.git.created_files)
touchedFiles.forEach((file) => {
const isGeneralHookFile = file.endsWith('hooks.ts') || file.endsWith('hooks.tsx')
const isNonSpecificHookFile = file.includes('/hooks/') && !file.includes('/use')
const sharedWarningExampleExplanation = `e.g. \`hooks/useXXX.ts{x}\`. This helps in development for discovery and navigation purposes.`
if (isGeneralHookFile) {
warn(
`\`${file}\` should be split out into a \`hooks/*\` folder with a hook per file, ${sharedWarningExampleExplanation}`,
)
}
if (isNonSpecificHookFile) {
warn(
`\`${file}\` should only have one exported hook per file and be named accordingly, ${sharedWarningExampleExplanation}`,
)
}
})
}
// Put any files here that we explicitly want to ignore!
const IGNORED_SPLIT_RULE_FILES: string[] = []
function checkSplitFiles() {
const touchedFiles = danger.git.modified_files.concat(danger.git.created_files)
touchedFiles.forEach((file) => {
const isWebFile = file.endsWith('.web.ts') || file.endsWith('.web.tsx')
const isNativeFile = file.endsWith('.native.ts') || file.endsWith('.native.tsx')
if ((!isWebFile && !isNativeFile) || IGNORED_SPLIT_RULE_FILES.includes(file)) {
return
}
const baseFile = file.substring(0, file.indexOf(isWebFile ? '.web.ts' : '.native.ts'))
const extension = file.indexOf('.tsx') !== -1 ? 'tsx' : 'ts'
if (isWebFile && !fs.existsSync(`${dirname(__filename)}/${baseFile}.native.${extension}`)) {
fail(`\`${baseFile}.web.${extension}\` must also have a \`${baseFile}.native.${extension}\` file.`)
}
if (isNativeFile && !fs.existsSync(`${dirname(__filename)}/${baseFile}.web.${extension}`)) {
fail(`\`${baseFile}.native.${extension}\` must also have a \`${baseFile}.web.${extension}\` file.`)
}
if (!fs.existsSync(`${dirname(__filename)}/${baseFile}.${extension}`)) {
fail(`\`${file}\` must have base stub file \`${baseFile}.${extension}\``)
}
})
}
async function processAddChanges() {
const updatedTsFiles = danger.git.modified_files
.concat(danger.git.created_files)
.filter((file) => (file.endsWith('.ts') || file.endsWith('.tsx')) && !file.includes('dangerfile.ts'))
const updatedNonUITsFiles = updatedTsFiles.filter((file) => !file.includes('packages/ui'))
const linesAddedByFile = await getLinesAddedByFile(updatedTsFiles)
const allLinesAdded = linesAddedByFile.flatMap((x) => x)
// Check for non-UI package lines for tamagui imports
const allNonUILinesAddedByFile = await getLinesAddedByFile(updatedNonUITsFiles, {
exclude: ['env.d.ts', 'tamaguiProvider.tsx'],
})
const allNonUILinesAdded = allNonUILinesAddedByFile.flatMap((x) => x)
allNonUILinesAdded.forEach((change) => {
if (change.content.includes(`from 'tamagui`)) {
fail(`Please import any tamagui exports via the ui package. Found an import at ${change.content}`)
}
})
// Checks for any logging and reminds the developer not to log sensitive data
if (allLinesAdded.some((change) => change.content.includes('logMessage') || change.content.includes('logger.'))) {
warn('You are logging data. Please confirm that nothing sensitive is being logged!')
}
// Check for usage of FlatList, FlashList, VirtualizedList, or ScrollView in modals
allLinesAdded.forEach((change) => {
if (
change.content.includes('FlatList') ||
change.content.includes('FlashList') ||
change.content.includes('VirtualizedList') ||
change.content.includes('ScrollView')
) {
warn(
`Detected usage of ${change.content.match(/FlatList|FlashList|VirtualizedList|ScrollView/g)?.join(', ')}. If it's used in a modal, please use the appropriate import from '@gorhom/bottom-sheet' instead.`,
)
}
})
// Check for imports from @gorhom/bottom-sheet
allLinesAdded.forEach((change) => {
if (
change.content.includes('@gorhom/bottom-sheet') &&
(change.content.includes('BottomSheetScrollView') ||
change.content.includes('BottomSheetFlatList') ||
change.content.includes('BottomSheetFlashList'))
) {
warn(
`Detected import from '@gorhom/bottom-sheet' for ${change.content.match(/BottomSheetScrollView|BottomSheetFlatList|BottomSheetFlashList/g)?.join(', ')}. Consider adding the focus hook from 'useBottomSheetFocusHook' to ensure scrollables work correctly, especially on Android.`,
)
}
})
// Check for UI package imports that are longer than needed
const validLongerImports = [
`'ui/src'`,
`'ui/src/storybook'`,
`'ui/src/theme'`,
`'ui/src/loading'`,
`'ui/src/assets'`,
`'ui/src/components/icons'`,
`'ui/src/icons'`,
`'ui/src/animations'`,
`'ui/src/hooks/useDeviceDimensions'`,
`'ui/src/hooks/useDeviceInsets'`,
`'ui/src/components/layout/AnimatedFlex'`,
`'ui/src/components/text/AnimatedText'`,
`'ui/src/components/AnimatedFlashList/AnimatedFlashList'`,
]
const longestImportLength = Math.max(...validLongerImports.map((i) => i.length))
allNonUILinesAdded.forEach((change) => {
const indices = getIndicesOf(`from 'ui/src/`, change.content)
indices.forEach((idx) => {
const potentialSubstring = change.content.substring(
idx,
Math.min(change.content.length, idx + longestImportLength + 6 + 1),
)
if (!validLongerImports.some((validImport) => potentialSubstring.includes(validImport))) {
const endOfImport = change.content.indexOf(`'`, idx + 6) // skipping the "from '"
warn(
`It looks like you have a longer import from 'ui/src' than needed ('${change.content.substring(idx + 6, endOfImport)}'). Please use one of [${validLongerImports.join(', ')}] when possible!`,
)
}
})
})
linesAddedByFile.forEach((linesAdded) => {
const concatenatedAddedLines = linesAdded.reduce((acc, curr) => acc + curr.content, '')
// In this section we concatenate all the added lines by file in order to account for multiline changes.
// Check for non-recommended sentry usage
if (/logger\.error\(\s*new Error\(/.test(concatenatedAddedLines)) {
warn(
`It appears you may be manually logging a Sentry error. Please log the error directly if possible. If you need to use a custom error message, ensure the error object is added to the 'cause' property.`,
)
}
if (/logger\.error\(\s*['`"]/.test(concatenatedAddedLines)) {
warn(`Please log an error, not a string!`)
}
// Check for incorrect usage of `createSelector`
if (concatenatedAddedLines.includes(`createSelector(`)) {
warn(
"You've added a new call to `createSelector()`. This is Ok, but please make sure you're using it correctly and you're not creating a new selector on every render. See PR #5172 for details.",
)
}
if (/(useSelector|appSelect|select)\(\s*makeSelect/.test(concatenatedAddedLines)) {
fail(
`It appears you may be creating a new selector on every render. See PR #5172 for details on how to fix this.`,
)
}
})
}
async function checkCocoaPodsVersion() {
const updatedPodFileLock = danger.git.modified_files.find((file) => file.includes('ios/Podfile.lock'))
if (updatedPodFileLock) {
const structuredDiff = await danger.git.structuredDiffForFile(updatedPodFileLock)
const changedLines = (structuredDiff?.chunks || []).flatMap((chunk) => {
return chunk.changes.filter((change) => change.type === 'add')
})
const changedCocoaPodsVersion = changedLines.some((change) => change.content.includes('COCOAPODS: '))
if (changedCocoaPodsVersion) {
warn(
`You're changing the Podfile version! Ensure you are using the correct version. If this change is intentional, you should ignore this check and merge anyways.`,
)
}
}
}
async function checkApostrophes() {
const updatedTranslations = danger.git.modified_files.find((file) => file.includes('en-US.json'))
if (updatedTranslations) {
const structuredDiff = await danger.git.structuredDiffForFile(updatedTranslations)
const changedLines = (structuredDiff?.chunks || []).flatMap((chunk) => {
return chunk.changes.filter((change) => change.type === 'add')
})
changedLines.forEach((line, index) => {
if (line.content.includes("'")) {
fail(
"You added a string to the translations file using the ' character. Please use the ’ character instead!. Issue in line: " +
index,
)
}
})
}
}
async function checkPRSize() {
// Warn when there is a big PR
const bigPRThreshold = 500
const linesCount = await danger.git.linesOfCode('**/*')
// exclude fixtures and auto generated files
const excludeLinesCount = await danger.git.linesOfCode('{**/*.snap}')
const totalLinesCount = (linesCount ?? 0) - (excludeLinesCount ?? 0)
if (totalLinesCount > bigPRThreshold) {
warn(':exclamation: Big PR')
markdown(
'> Pull Request size seems relatively large. If PR contains multiple changes, split each into separate PRs for faster, easier reviews.',
)
}
}
/* Warn about storing credentials in GH and uploading env.local to 1Password */
const envChanged = danger.git.modified_files.includes('.env.defaults')
if (envChanged) {
warn(
'Changes were made to .env.defaults. Confirm that no sensitive data is in the .env.defaults file. Sensitive data must go in .env (web) or .env.defaults.local (mobile) and then run `yarn upload-env-local` to store it in 1Password.',
)
}
// Check locked dependencies
checkLockedDependencies()
// Check native and web file splits
checkSplitFiles()
// Check hook file pattern
checkGeneralizedHookFiles()
// Run checks on added changes
processAddChanges()
// Check for cocoapods version change
checkCocoaPodsVersion()
// check translations use the correct apostrophes
checkApostrophes()
// check the PR size
checkPRSize()
// No PR is too small to warrant a paragraph or two of summary
if (danger.github.pr.body.length < 50) {
warn(
'The PR description is looking sparse. Please consider explaining more about this PRs goal and implementation decisions.',
)
}
// Congratulate when code was deleted
if (danger.github.pr.additions < danger.github.pr.deletions) {
message(`✂️ Thanks for removing ${danger.github.pr.deletions - danger.github.pr.additions} lines!`)
}
// GraphQL update warnings
const updatedGraphQLfile = danger.git.modified_files.find((file) => file.endsWith('.graphql'))
if (updatedGraphQLfile) {
warn(
'You have updated the GraphQL schema. Please ensure that the Swift GraphQL Schema generation is valid by running `yarn mobile ios` and rebuilding for iOS. ' +
'You may need to add or remove generated files to the project.pbxproj. For more information see `apps/mobile/ios/WidgetsCore/MobileSchema/README.md`',
)
}
// Migrations + schema warnings
const updatedMobileSchemaFile = danger.git.modified_files.find((file) => file.includes('mobile/src/app/schema.ts'))
const updatedMobileMigrationsFile = danger.git.modified_files.find((file) =>
file.includes('mobile/src/app/migrations.ts'),
)
const updatedMobileMigrationsTestFile = danger.git.modified_files.find((file) =>
file.includes('mobile/src/app/migrations.test.ts'),
)
const updatedExtensionSchemaFile = danger.git.modified_files.find((file) =>
file.includes('extension/src/app/schema.ts'),
)
const updatedExtensionMigrationsFile = danger.git.modified_files.find((file) =>
file.includes('extension/src/store/migrations.ts'),
)
const updatedExtensionMigrationsTestFile = danger.git.modified_files.find((file) =>
file.includes('extension/src/store/migrations.test.ts'),
)
const createdSliceFile = danger.git.created_files.find((file) => file.toLowerCase().includes('slice'))
const modifiedSliceFile = danger.git.modified_files.find((file) => file.toLowerCase().includes('slice'))
const deletedSliceFile = danger.git.deleted_files.find((file) => file.toLowerCase().includes('slice'))
if (
modifiedSliceFile &&
(!updatedMobileSchemaFile ||
!updatedMobileMigrationsFile ||
!updatedExtensionSchemaFile ||
!updatedExtensionMigrationsFile)
) {
warn(
'You modified a slice file. If you added, renamed, or deleted required properties from state, then make sure to define a new schema and a create a migration.',
)
}
if (updatedMobileSchemaFile && !updatedMobileMigrationsFile) {
warn('You updated the mobile schema file but not the migrations file. Make sure to also define a migration.')
}
if (updatedExtensionSchemaFile && !updatedExtensionMigrationsFile) {
warn('You updated the extension schema file but not the migrations file. Make sure to also define a migration.')
}
if (!updatedMobileSchemaFile && updatedMobileMigrationsFile) {
warn(
'You updated the mobile migrations file but not the schema. Schema always needs to be updated when a new migration is defined.',
)
}
if (!updatedExtensionSchemaFile && updatedExtensionMigrationsFile) {
warn(
'You updated the extension migrations file but not the schema. Schema always needs to be updated when a new migration is defined.',
)
}
if (
(createdSliceFile || deletedSliceFile) &&
(!updatedMobileSchemaFile ||
!updatedMobileMigrationsFile ||
!updatedExtensionSchemaFile ||
!updatedExtensionMigrationsFile)
) {
warn('You created or deleted a slice file. Make sure to update the schema and create migration if needed.')
}
if (
(updatedMobileMigrationsFile && !updatedMobileMigrationsTestFile) ||
(updatedExtensionMigrationsFile && !updatedExtensionMigrationsTestFile)
) {
warn('You updated the migrations file but did not write any new tests. Each migration must have a test!')
}
if (updatedMobileMigrationsFile !== updatedExtensionMigrationsFile) {
warn(
'You updated the migrations file in one app but not the other. Make sure to update both migration files if needed.',
)
}