10.7 KB330 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Tracker} from 'isl-server/src/analytics/tracker';
9import type {UseUncommittedSelection} from './partialSelection';
10import type {CommitInfo, Diagnostic, DiagnosticAllowlist} from './types';
11
12import * as stylex from '@stylexjs/stylex';
13import {Checkbox} from 'isl-components/Checkbox';
14import {Column, Row} from 'isl-components/Flex';
15import {Icon} from 'isl-components/Icon';
16import {Subtle} from 'isl-components/Subtle';
17import {Tooltip} from 'isl-components/Tooltip';
18import {useAtom} from 'jotai';
19import {basename} from 'shared/utils';
20import {spacing} from '../../components/theme/tokens.stylex';
21import serverAPI from './ClientToServerAPI';
22import {Collapsable} from './Collapsable';
23import {Internal} from './Internal';
24import {tracker} from './analytics';
25import {getFeatureFlag} from './featureFlags';
26import {T, t} from './i18n';
27import {localStorageBackedAtom, readAtom} from './jotaiUtils';
28import platform from './platform';
29import {uncommittedChangesWithPreviews} from './previews';
30import {showModal} from './useModal';
31
32export const shouldWarnAboutDiagnosticsAtom = localStorageBackedAtom<boolean>(
33 'isl.warn-about-diagnostics',
34 true,
35);
36
37const hideNonBlockingDiagnosticsAtom = localStorageBackedAtom<boolean>(
38 'isl.hide-non-blocking-diagnostics',
39 true,
40);
41
42const styles = stylex.create({
43 diagnosticList: {
44 paddingInline: spacing.double,
45 paddingBlock: spacing.half,
46 gap: 0,
47 },
48 nowrap: {
49 whiteSpace: 'nowrap',
50 },
51 diagnosticRow: {
52 maxWidth: 'max(400px, 80vw)',
53 padding: spacing.half,
54 cursor: 'pointer',
55 ':hover': {
56 backgroundColor: 'var(--hover-darken)',
57 },
58 },
59 allDiagnostics: {
60 maxHeight: 'calc(100vh - 200px)',
61 minHeight: '50px',
62 overflowY: 'scroll',
63 },
64 confirmCheckbox: {
65 paddingTop: spacing.double,
66 },
67});
68
69export function isBlockingDiagnostic(
70 d: Diagnostic,
71 /** Many diagnostics are low-quality and don't reflect what would appear on CI.
72 * Start with an allowlist while we validate which signals are worthwhile. */
73 allowlistedCodesBySource: undefined | DiagnosticAllowlist = Internal.allowlistedDiagnosticCodes ??
74 undefined,
75): boolean {
76 if (allowlistedCodesBySource == null) {
77 // In OSS, let's assume all errors are blocking.
78 return true;
79 }
80 if (d.severity !== 'error' && d.severity !== 'warning') {
81 return false;
82 }
83 if (allowlistedCodesBySource == null) {
84 return true;
85 }
86 // source/code may be missing, but we still want to route that through the allowlist
87 const source = d.source ?? 'undefined';
88 const code = d.code ?? 'undefined';
89 const relevantAllowlist = allowlistedCodesBySource.get(d.severity)?.get(source);
90 return (
91 relevantAllowlist != null &&
92 (relevantAllowlist.allow
93 ? relevantAllowlist.allow.has(code) === true
94 : relevantAllowlist.block.has(code) === false)
95 );
96}
97
98function isErrorDiagnosticToLog(d: Diagnostic): boolean {
99 return d.severity === 'error';
100}
101
102/** Render diagnostic to a string, in the format `Source(Code): Snippet of error message` */
103function previewDiagnostic(diagnostic: Diagnostic | undefined) {
104 return diagnostic != null
105 ? `${diagnostic.source}(${diagnostic.code}): ${diagnostic?.message.slice(0, 100)}`
106 : undefined;
107}
108
109/**
110 * Check IDE diagnostics for files that will be commit/amended/submitted,
111 * to confirm if they intended the errors.
112 */
113export async function confirmNoBlockingDiagnostics(
114 /** Check diagnostics for these selected files. */
115 selection: UseUncommittedSelection,
116 /** If provided, warn for changes to files in this commit. Used when checking diagnostics when amending a commit. */
117 commit?: CommitInfo,
118): Promise<boolean> {
119 if (!readAtom(shouldWarnAboutDiagnosticsAtom)) {
120 return true;
121 }
122 if (platform.platformName === 'vscode') {
123 const allFiles = new Set<string>();
124 for (const file of readAtom(uncommittedChangesWithPreviews)) {
125 if (selection.isFullyOrPartiallySelected(file.path)) {
126 allFiles.add(file.path);
127 }
128 }
129 for (const filePath of commit?.filePathsSample ?? []) {
130 allFiles.add(filePath);
131 }
132
133 serverAPI.postMessage({
134 type: 'platform/checkForDiagnostics',
135 paths: [...allFiles],
136 });
137 const [result, enabled] = await Promise.all([
138 serverAPI.nextMessageMatching('platform/gotDiagnostics', () => true),
139 getFeatureFlag(
140 Internal.featureFlags?.ShowPresubmitDiagnosticsWarning,
141 /* enable this feature in OSS */ true,
142 ),
143 ]);
144 if (result.diagnostics.size > 0) {
145 const allDiagnostics = [...result.diagnostics.values()];
146 const allBlockingErrors = allDiagnostics
147 .map(value => value.filter(d => isBlockingDiagnostic(d)))
148 .flat();
149 const totalErrors = allBlockingErrors.length;
150
151 // It's useful to track even the diagnostics that are filtered out, to refine the allowlist in the future
152 const unfilteredErrors = allDiagnostics
153 .map(value => value.filter(isErrorDiagnosticToLog))
154 .flat();
155
156 const totalDiagnostics = allDiagnostics.flat().length;
157
158 const firstError = allBlockingErrors[0];
159 const firstUnfilteredError = unfilteredErrors[0];
160
161 const childTracker = tracker.trackAsParent('DiagnosticsConfirmationOpportunity', {
162 extras: {
163 shown: enabled,
164 unfilteredErrorCodes: [...new Set(unfilteredErrors.map(d => `${d.source}(${d.code})`))],
165 filteredErrorCodes: [...new Set(allBlockingErrors.map(d => `${d.source}(${d.code})`))],
166 sampleMessage: previewDiagnostic(firstError),
167 unfilteredSampleMessage: previewDiagnostic(firstUnfilteredError),
168 totalUnfilteredErrors: unfilteredErrors.length,
169 totalErrors,
170 totalDiagnostics,
171 },
172 });
173
174 if (!enabled) {
175 return true;
176 }
177
178 if (totalErrors > 0) {
179 const buttons = [{label: 'Cancel'}, {label: 'Continue', primary: true}] as const;
180 const shouldContinue =
181 (await showModal({
182 type: 'confirm',
183 title: (
184 <Row>
185 <T count={totalErrors}>codeIssuesFound</T>
186 <Tooltip
187 title={t(
188 'Error-severity issues are typically land blocking and should be resolved before submitting for code review.\n\n' +
189 'Errors shown here are best-effort and not necessarily comprehensive.',
190 )}>
191 <Icon icon="info" />
192 </Tooltip>
193 </Row>
194 ),
195 message: (
196 <DiagnosticsList
197 diagnostics={[...result.diagnostics.entries()]}
198 tracker={childTracker}
199 />
200 ),
201 buttons,
202 })) === buttons[1];
203
204 childTracker.track('DiagnosticsConfirmationAction', {
205 extras: {
206 action: shouldContinue ? 'continue' : 'cancel',
207 },
208 });
209
210 return shouldContinue;
211 }
212 }
213 }
214 return true;
215}
216
217function DiagnosticsList({
218 diagnostics,
219 tracker,
220}: {
221 diagnostics: Array<[string, Array<Diagnostic>]>;
222 tracker: Tracker<{parentId: string}>;
223}) {
224 const [hideNonBlocking, setHideNonBlocking] = useAtom(hideNonBlockingDiagnosticsAtom);
225 const [shouldWarn, setShouldWarn] = useAtom(shouldWarnAboutDiagnosticsAtom);
226 return (
227 <>
228 <Column alignStart xstyle={styles.allDiagnostics}>
229 {diagnostics.map(([filepath, diagnostics]) => {
230 const sortedDiagnostics = [...diagnostics].filter(d =>
231 hideNonBlocking ? isBlockingDiagnostic(d) : true,
232 );
233 sortedDiagnostics.sort((a, b) => {
234 return severityComparator(a) - severityComparator(b);
235 });
236 if (sortedDiagnostics.length === 0) {
237 return null;
238 }
239 return (
240 <Column key={filepath} alignStart>
241 <Collapsable
242 startExpanded
243 title={
244 <Row>
245 <span>{basename(filepath)}</span>
246 <Subtle>{filepath}</Subtle>
247 </Row>
248 }>
249 <Column alignStart xstyle={styles.diagnosticList}>
250 {sortedDiagnostics.map((d, i) => (
251 <Row
252 role="button"
253 tabIndex={0}
254 key={i}
255 xstyle={styles.diagnosticRow}
256 onClick={() => {
257 platform.openFile(filepath, {line: d.range.startLine + 1});
258 tracker.track('DiagnosticsConfirmationAction', {
259 extras: {action: 'openFile'},
260 });
261 }}>
262 {iconForDiagnostic(d)}
263 <span>{d.message}</span>
264 {d.source && (
265 <Subtle {...stylex.props(styles.nowrap)}>
266 {d.source}
267 {d.code ? `(${d.code})` : null}
268 </Subtle>
269 )}{' '}
270 <Subtle {...stylex.props(styles.nowrap)}>
271 [Ln {d.range.startLine}, Col {d.range.startCol}]
272 </Subtle>
273 </Row>
274 ))}
275 </Column>
276 </Collapsable>
277 </Column>
278 );
279 })}
280 </Column>
281 <Row xstyle={styles.confirmCheckbox}>
282 <Checkbox
283 checked={!shouldWarn}
284 onChange={checked => {
285 setShouldWarn(!checked);
286 if (checked) {
287 tracker.track('DiagnosticsConfirmationAction', {extras: {action: 'dontAskAgain'}});
288 }
289 }}>
290 <T>Don't ask again</T>
291 </Checkbox>
292 <Tooltip
293 title={t(
294 "Only 'error' severity issues known to cause problems will cause this dialog to appear, but less severe issues can still be shown here. This option hides these non-blocking issues.",
295 )}>
296 <Checkbox checked={hideNonBlocking} onChange={setHideNonBlocking}>
297 <T>Hide non-blocking issues</T>
298 </Checkbox>
299 </Tooltip>
300 </Row>
301 </>
302 );
303}
304
305function severityComparator(a: Diagnostic) {
306 switch (a.severity) {
307 case 'error':
308 return 0;
309 case 'warning':
310 return 1;
311 case 'info':
312 return 2;
313 case 'hint':
314 return 3;
315 }
316}
317
318function iconForDiagnostic(d: Diagnostic): React.ReactNode {
319 switch (d.severity) {
320 case 'error':
321 return <Icon icon="error" color="red" />;
322 case 'warning':
323 return <Icon icon="warning" color="yellow" />;
324 case 'info':
325 return <Icon icon="info" />;
326 case 'hint':
327 return <Icon icon="info" />;
328 }
329}
330