Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/script/eslintGitBlameReport/generateEslintIgnoreReport.ts
13388 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { spawnSync, SpawnSyncOptions } from 'child_process';
7
import { createHash } from 'crypto';
8
import { promises as fs } from 'fs';
9
import * as path from 'path';
10
11
interface ESLintMessage {
12
ruleId: string | null;
13
severity: number;
14
message: string;
15
line: number;
16
column: number;
17
}
18
19
interface ESLintResult {
20
filePath: string;
21
messages: ESLintMessage[];
22
}
23
24
interface CommitHandleCache {
25
[commit: string]: string;
26
}
27
28
const owner = 'microsoft';
29
const repo = 'vscode-copilot-chat';
30
const repoRoot = path.resolve(__dirname, '../..');
31
const alternateRepoRoot = path.resolve(repoRoot, '..', 'vscode-copilot');
32
const lintCacheDir = path.join(repoRoot, '.lint-cache');
33
const lintOutputPath = path.join(lintCacheDir, 'eslint-output.json');
34
const commitHandleCachePath = path.join(lintCacheDir, 'commit-handles.json');
35
const failedHandleCommits = new Set<string>();
36
const alternateRepoHandleCache = new Map<string, string | null>();
37
38
let alternateRepoAvailability: boolean | undefined;
39
40
void main().catch(error => {
41
console.error(error instanceof Error ? error.message : error);
42
process.exit(1);
43
});
44
45
async function main(): Promise<void> {
46
await fs.mkdir(lintCacheDir, { recursive: true });
47
48
const { cacheKey, results } = await getLintResults();
49
const violatingFiles = collectViolations(results);
50
51
if (!violatingFiles.size) {
52
console.log('No ESLint violations detected.');
53
return;
54
}
55
56
const commitHandles = await loadCommitHandles();
57
let cacheDirty = false;
58
const reportLines: string[] = [];
59
60
for (const [file, messages] of violatingFiles) {
61
const resolvedMessages: { message: ESLintMessage; username: string }[] = [];
62
63
for (const message of messages) {
64
const handle = await resolveHandleForMessage(file, message.line, commitHandles);
65
if (handle.commit) {
66
commitHandles[handle.commit] = handle.username;
67
}
68
cacheDirty = cacheDirty || handle.isNew;
69
resolvedMessages.push({ message, username: handle.username });
70
}
71
72
const uniqueHandles = new Set(resolvedMessages.map(entry => entry.username));
73
if (uniqueHandles.size === 1 && resolvedMessages.length) {
74
const onlyHandle = resolvedMessages[0].username;
75
reportLines.push(`- [ ] ${file} @${onlyHandle}`);
76
} else {
77
reportLines.push(`- [ ] ${file}`);
78
for (const { message, username } of resolvedMessages) {
79
reportLines.push(formatReportLine(message, username));
80
}
81
}
82
reportLines.push('');
83
}
84
85
if (cacheDirty) {
86
await fs.writeFile(commitHandleCachePath, JSON.stringify(commitHandles, null, 2), 'utf8');
87
}
88
89
await updateEslintIgnores(Array.from(violatingFiles.keys()));
90
91
console.log(reportLines.join('\n'));
92
console.log(`Cached lint results key: ${cacheKey}`);
93
}
94
95
async function getLintResults(): Promise<{ cacheKey: string; results: ESLintResult[] }> {
96
const gitHead = runGit(['rev-parse', 'HEAD']);
97
const gitStatus = runGit(['status', '--porcelain']);
98
const cacheKey = createHash('sha1').update(`${gitHead}\n${gitStatus}`).digest('hex');
99
const cacheFile = path.join(lintCacheDir, `${cacheKey}.json`);
100
101
if (await fileExists(cacheFile)) {
102
const cached = await fs.readFile(cacheFile, 'utf8');
103
return { cacheKey, results: JSON.parse(cached) as ESLintResult[] };
104
}
105
106
await fs.rm(lintOutputPath, { force: true });
107
runLintCommand();
108
109
const lintOutput = await fs.readFile(lintOutputPath, 'utf8');
110
const parsed = JSON.parse(lintOutput) as ESLintResult[];
111
await fs.writeFile(cacheFile, JSON.stringify(parsed, null, 2), 'utf8');
112
113
return { cacheKey, results: parsed };
114
}
115
116
function runLintCommand(): void {
117
const cacheLocation = path.join(lintCacheDir, '.eslintcache');
118
const args = ['run', 'lint', '--', '--format', 'json', '--output-file', lintOutputPath, '--cache', '--cache-location', cacheLocation];
119
const result = spawnSync('npm', args, spawnOptions());
120
121
if (result.error) {
122
throw result.error;
123
}
124
125
if (result.status !== 0 && result.status !== 1) {
126
throw new Error(`npm run lint failed with exit code ${result.status ?? 'unknown'}`);
127
}
128
}
129
130
function spawnOptions(): SpawnSyncOptions {
131
return {
132
cwd: repoRoot,
133
stdio: 'inherit'
134
};
135
}
136
137
function collectViolations(results: ESLintResult[]): Map<string, ESLintMessage[]> {
138
const violations = new Map<string, ESLintMessage[]>();
139
140
for (const result of results) {
141
const relevantMessages = result.messages.filter(message => message.severity > 0);
142
if (!relevantMessages.length) {
143
continue;
144
}
145
146
const relativeFile = toPosixPath(path.relative(repoRoot, result.filePath));
147
const prefixed = relativeFile.startsWith('.') ? relativeFile : `./${relativeFile}`;
148
violations.set(prefixed, relevantMessages);
149
}
150
151
return violations;
152
}
153
154
async function loadCommitHandles(): Promise<CommitHandleCache> {
155
if (!(await fileExists(commitHandleCachePath))) {
156
return {};
157
}
158
159
const raw = await fs.readFile(commitHandleCachePath, 'utf8');
160
try {
161
return JSON.parse(raw) as CommitHandleCache;
162
} catch (error) {
163
console.warn('Failed to parse commit handle cache, starting fresh.');
164
return {};
165
}
166
}
167
168
interface HandleResolution {
169
commit?: string;
170
username: string;
171
isNew: boolean;
172
}
173
174
async function resolveHandleForMessage(file: string, line: number, cache: CommitHandleCache): Promise<HandleResolution> {
175
let blameCommit: string | undefined;
176
try {
177
blameCommit = extractCommitHash(runGit(['blame', '--line-porcelain', '-L', `${line},${line}`, file]));
178
} catch (error) {
179
throw new Error(`Failed to run git blame for ${file}:${line}: ${error instanceof Error ? error.message : String(error)}`);
180
}
181
const blameHandle = await getHandleForCommit(blameCommit, cache);
182
183
if (blameHandle && blameHandle.username !== 'kieferrm') {
184
return { commit: blameCommit, username: blameHandle.username, isNew: blameHandle.isNew };
185
}
186
187
if (blameHandle && blameHandle.username === 'kieferrm') {
188
const alternateHandle = await resolveHandleFromAlternateRepo(file);
189
if (alternateHandle) {
190
return { username: alternateHandle, isNew: false };
191
}
192
}
193
194
let lastCommit: string | undefined;
195
try {
196
lastCommit = extractCommitHash(runGit(['log', '-n', '1', '--pretty=format:%H', '--', file]));
197
} catch (error) {
198
throw new Error(`Failed to find last change for ${file}: ${error instanceof Error ? error.message : String(error)}`);
199
}
200
201
const fallbackHandle = await getHandleForCommit(lastCommit, cache);
202
if (fallbackHandle) {
203
if (fallbackHandle.username === 'kieferrm') {
204
const alternateHandle = await resolveHandleFromAlternateRepo(file);
205
if (alternateHandle) {
206
return { username: alternateHandle, isNew: false };
207
}
208
}
209
return { commit: lastCommit, username: fallbackHandle.username, isNew: fallbackHandle.isNew };
210
}
211
212
return { username: 'kieferrm', isNew: false };
213
}
214
215
interface CommitHandleLookup {
216
username: string;
217
isNew: boolean;
218
}
219
220
221
async function getHandleForCommit(commit: string | undefined, cache: CommitHandleCache): Promise<CommitHandleLookup | undefined> {
222
if (!commit) {
223
return undefined;
224
}
225
226
if (cache[commit]) {
227
return { username: cache[commit], isNew: false };
228
}
229
230
if (failedHandleCommits.has(commit)) {
231
return undefined;
232
}
233
234
let login: string | undefined;
235
const env = {
236
...process.env,
237
GH_PAGER: 'cat',
238
GH_PROMPT_DISABLED: '1'
239
};
240
241
const response = spawnSync('gh', ['api', `/repos/${owner}/${repo}/commits/${commit}`], {
242
cwd: repoRoot,
243
encoding: 'utf8',
244
env
245
});
246
247
if (response.status === 0 && response.stdout) {
248
try {
249
const data = JSON.parse(response.stdout);
250
login = data.author?.login ?? data.committer?.login ?? data.commit?.author?.name;
251
} catch (error) {
252
console.warn(`Failed to parse GitHub API response for commit ${commit}`);
253
}
254
} else if (response.status !== 0) {
255
const stderr = typeof response.stderr === 'string' ? response.stderr.trim() : '';
256
console.warn(`gh api commit ${commit} exited with code ${response.status}${stderr ? `: ${stderr}` : ''}`);
257
}
258
259
if (!login) {
260
login = getHandleFromLocalGit(commit);
261
}
262
263
if (!login) {
264
failedHandleCommits.add(commit);
265
console.warn(`Unable to resolve GitHub handle for commit ${commit}`);
266
return undefined;
267
}
268
269
const normalized = normalizeHandle(login);
270
cache[commit] = normalized;
271
return { username: normalized, isNew: true };
272
}
273
274
function getHandleFromLocalGit(commit: string): string | undefined {
275
try {
276
const email = runGit(['show', '-s', '--format=%ae', commit]);
277
const handleFromEmail = extractHandleFromEmail(email);
278
if (handleFromEmail) {
279
return handleFromEmail;
280
}
281
const author = runGit(['show', '-s', '--format=%an', commit]);
282
return normalizePossibleHandle(author);
283
} catch {
284
return undefined;
285
}
286
}
287
288
function extractHandleFromEmail(email: string): string | undefined {
289
const noreplyPattern = /^(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com$/;
290
const match = email.match(noreplyPattern);
291
if (match) {
292
return match[1];
293
}
294
return undefined;
295
}
296
297
function normalizePossibleHandle(name: string): string | undefined {
298
const normalized = name.trim();
299
if (!normalized || /\s/.test(normalized)) {
300
return undefined;
301
}
302
return normalized;
303
}
304
305
function normalizeHandle(handle: string): string {
306
return handle.startsWith('@') ? handle.substring(1) : handle;
307
}
308
309
function extractCommitHash(blameOutput: string): string | undefined {
310
const firstLine = blameOutput.split('\n')[0]?.trim();
311
if (!firstLine) {
312
return undefined;
313
}
314
315
const commit = firstLine.split(' ')[0];
316
if (!commit || /^[0]+$/.test(commit)) {
317
return undefined;
318
}
319
320
return commit.startsWith('^') ? commit.substring(1) : commit;
321
}
322
323
function formatReportLine(message: ESLintMessage, handle: string): string {
324
const rule = message.ruleId ?? '';
325
const column = message.column ?? 0;
326
const line = `${message.line}:${column}`;
327
const components = [` - [ ] ${line}`];
328
if (rule) {
329
components.push(rule);
330
}
331
if (handle) {
332
components.push(`@${handle}`);
333
}
334
return components.join(' ');
335
}
336
337
async function updateEslintIgnores(files: string[]): Promise<void> {
338
if (!files.length) {
339
return;
340
}
341
342
const configPath = path.join(repoRoot, 'ignores.md');
343
const nextContent = files.map(file => `'${file}'`).join(',\n');
344
await fs.writeFile(configPath, nextContent, 'utf8');
345
}
346
347
function toPosixPath(input: string): string {
348
return input.split(path.sep).join('/');
349
}
350
351
function runGit(args: string[]): string {
352
return runGitCommand(repoRoot, args);
353
}
354
355
async function fileExists(filePath: string): Promise<boolean> {
356
try {
357
await fs.stat(filePath);
358
return true;
359
} catch {
360
return false;
361
}
362
}
363
364
async function resolveHandleFromAlternateRepo(file: string): Promise<string | undefined> {
365
if (alternateRepoHandleCache.has(file)) {
366
const cached = alternateRepoHandleCache.get(file);
367
return cached ?? undefined;
368
}
369
370
if (!(await hasAlternateRepo())) {
371
alternateRepoHandleCache.set(file, null);
372
return undefined;
373
}
374
375
const relativeFile = file.startsWith('./') ? file.substring(2) : file;
376
const fileForGit = relativeFile.split('/').join(path.sep);
377
const absolutePath = path.join(alternateRepoRoot, fileForGit);
378
379
if (!(await fileExists(absolutePath))) {
380
alternateRepoHandleCache.set(file, null);
381
return undefined;
382
}
383
384
try {
385
const lastCommit = runGitCommand(alternateRepoRoot, ['log', '-n', '1', '--pretty=format:%H', '--', fileForGit]);
386
if (!lastCommit) {
387
alternateRepoHandleCache.set(file, null);
388
return undefined;
389
}
390
391
const email = runGitCommand(alternateRepoRoot, ['show', '-s', '--format=%ae', lastCommit]);
392
const handleFromEmail = extractHandleFromEmail(email);
393
let resolvedHandle = handleFromEmail ? normalizeHandle(handleFromEmail) : undefined;
394
395
if (!resolvedHandle) {
396
const author = runGitCommand(alternateRepoRoot, ['show', '-s', '--format=%an', lastCommit]);
397
const possibleHandle = normalizePossibleHandle(author);
398
if (possibleHandle) {
399
resolvedHandle = normalizeHandle(possibleHandle);
400
}
401
}
402
403
if (resolvedHandle) {
404
alternateRepoHandleCache.set(file, resolvedHandle);
405
return resolvedHandle;
406
}
407
} catch (error) {
408
console.warn(`Failed to resolve alternate repo handle for ${file}${error instanceof Error ? `: ${error.message}` : ''}`);
409
}
410
411
alternateRepoHandleCache.set(file, null);
412
return undefined;
413
}
414
415
async function hasAlternateRepo(): Promise<boolean> {
416
if (alternateRepoAvailability !== undefined) {
417
return alternateRepoAvailability;
418
}
419
420
try {
421
const stats = await fs.stat(alternateRepoRoot);
422
alternateRepoAvailability = stats.isDirectory();
423
} catch {
424
alternateRepoAvailability = false;
425
}
426
427
return alternateRepoAvailability;
428
}
429
430
function runGitCommand(cwd: string, args: string[]): string {
431
const result = spawnSync('git', args, {
432
cwd,
433
encoding: 'utf8'
434
});
435
436
if (result.status !== 0) {
437
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
438
}
439
440
return (result.stdout ?? '').trim();
441
}
442
443