Path: blob/main/extensions/copilot/script/eslintGitBlameReport/generateEslintIgnoreReport.ts
13388 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { spawnSync, SpawnSyncOptions } from 'child_process';6import { createHash } from 'crypto';7import { promises as fs } from 'fs';8import * as path from 'path';910interface ESLintMessage {11ruleId: string | null;12severity: number;13message: string;14line: number;15column: number;16}1718interface ESLintResult {19filePath: string;20messages: ESLintMessage[];21}2223interface CommitHandleCache {24[commit: string]: string;25}2627const owner = 'microsoft';28const repo = 'vscode-copilot-chat';29const repoRoot = path.resolve(__dirname, '../..');30const alternateRepoRoot = path.resolve(repoRoot, '..', 'vscode-copilot');31const lintCacheDir = path.join(repoRoot, '.lint-cache');32const lintOutputPath = path.join(lintCacheDir, 'eslint-output.json');33const commitHandleCachePath = path.join(lintCacheDir, 'commit-handles.json');34const failedHandleCommits = new Set<string>();35const alternateRepoHandleCache = new Map<string, string | null>();3637let alternateRepoAvailability: boolean | undefined;3839void main().catch(error => {40console.error(error instanceof Error ? error.message : error);41process.exit(1);42});4344async function main(): Promise<void> {45await fs.mkdir(lintCacheDir, { recursive: true });4647const { cacheKey, results } = await getLintResults();48const violatingFiles = collectViolations(results);4950if (!violatingFiles.size) {51console.log('No ESLint violations detected.');52return;53}5455const commitHandles = await loadCommitHandles();56let cacheDirty = false;57const reportLines: string[] = [];5859for (const [file, messages] of violatingFiles) {60const resolvedMessages: { message: ESLintMessage; username: string }[] = [];6162for (const message of messages) {63const handle = await resolveHandleForMessage(file, message.line, commitHandles);64if (handle.commit) {65commitHandles[handle.commit] = handle.username;66}67cacheDirty = cacheDirty || handle.isNew;68resolvedMessages.push({ message, username: handle.username });69}7071const uniqueHandles = new Set(resolvedMessages.map(entry => entry.username));72if (uniqueHandles.size === 1 && resolvedMessages.length) {73const onlyHandle = resolvedMessages[0].username;74reportLines.push(`- [ ] ${file} @${onlyHandle}`);75} else {76reportLines.push(`- [ ] ${file}`);77for (const { message, username } of resolvedMessages) {78reportLines.push(formatReportLine(message, username));79}80}81reportLines.push('');82}8384if (cacheDirty) {85await fs.writeFile(commitHandleCachePath, JSON.stringify(commitHandles, null, 2), 'utf8');86}8788await updateEslintIgnores(Array.from(violatingFiles.keys()));8990console.log(reportLines.join('\n'));91console.log(`Cached lint results key: ${cacheKey}`);92}9394async function getLintResults(): Promise<{ cacheKey: string; results: ESLintResult[] }> {95const gitHead = runGit(['rev-parse', 'HEAD']);96const gitStatus = runGit(['status', '--porcelain']);97const cacheKey = createHash('sha1').update(`${gitHead}\n${gitStatus}`).digest('hex');98const cacheFile = path.join(lintCacheDir, `${cacheKey}.json`);99100if (await fileExists(cacheFile)) {101const cached = await fs.readFile(cacheFile, 'utf8');102return { cacheKey, results: JSON.parse(cached) as ESLintResult[] };103}104105await fs.rm(lintOutputPath, { force: true });106runLintCommand();107108const lintOutput = await fs.readFile(lintOutputPath, 'utf8');109const parsed = JSON.parse(lintOutput) as ESLintResult[];110await fs.writeFile(cacheFile, JSON.stringify(parsed, null, 2), 'utf8');111112return { cacheKey, results: parsed };113}114115function runLintCommand(): void {116const cacheLocation = path.join(lintCacheDir, '.eslintcache');117const args = ['run', 'lint', '--', '--format', 'json', '--output-file', lintOutputPath, '--cache', '--cache-location', cacheLocation];118const result = spawnSync('npm', args, spawnOptions());119120if (result.error) {121throw result.error;122}123124if (result.status !== 0 && result.status !== 1) {125throw new Error(`npm run lint failed with exit code ${result.status ?? 'unknown'}`);126}127}128129function spawnOptions(): SpawnSyncOptions {130return {131cwd: repoRoot,132stdio: 'inherit'133};134}135136function collectViolations(results: ESLintResult[]): Map<string, ESLintMessage[]> {137const violations = new Map<string, ESLintMessage[]>();138139for (const result of results) {140const relevantMessages = result.messages.filter(message => message.severity > 0);141if (!relevantMessages.length) {142continue;143}144145const relativeFile = toPosixPath(path.relative(repoRoot, result.filePath));146const prefixed = relativeFile.startsWith('.') ? relativeFile : `./${relativeFile}`;147violations.set(prefixed, relevantMessages);148}149150return violations;151}152153async function loadCommitHandles(): Promise<CommitHandleCache> {154if (!(await fileExists(commitHandleCachePath))) {155return {};156}157158const raw = await fs.readFile(commitHandleCachePath, 'utf8');159try {160return JSON.parse(raw) as CommitHandleCache;161} catch (error) {162console.warn('Failed to parse commit handle cache, starting fresh.');163return {};164}165}166167interface HandleResolution {168commit?: string;169username: string;170isNew: boolean;171}172173async function resolveHandleForMessage(file: string, line: number, cache: CommitHandleCache): Promise<HandleResolution> {174let blameCommit: string | undefined;175try {176blameCommit = extractCommitHash(runGit(['blame', '--line-porcelain', '-L', `${line},${line}`, file]));177} catch (error) {178throw new Error(`Failed to run git blame for ${file}:${line}: ${error instanceof Error ? error.message : String(error)}`);179}180const blameHandle = await getHandleForCommit(blameCommit, cache);181182if (blameHandle && blameHandle.username !== 'kieferrm') {183return { commit: blameCommit, username: blameHandle.username, isNew: blameHandle.isNew };184}185186if (blameHandle && blameHandle.username === 'kieferrm') {187const alternateHandle = await resolveHandleFromAlternateRepo(file);188if (alternateHandle) {189return { username: alternateHandle, isNew: false };190}191}192193let lastCommit: string | undefined;194try {195lastCommit = extractCommitHash(runGit(['log', '-n', '1', '--pretty=format:%H', '--', file]));196} catch (error) {197throw new Error(`Failed to find last change for ${file}: ${error instanceof Error ? error.message : String(error)}`);198}199200const fallbackHandle = await getHandleForCommit(lastCommit, cache);201if (fallbackHandle) {202if (fallbackHandle.username === 'kieferrm') {203const alternateHandle = await resolveHandleFromAlternateRepo(file);204if (alternateHandle) {205return { username: alternateHandle, isNew: false };206}207}208return { commit: lastCommit, username: fallbackHandle.username, isNew: fallbackHandle.isNew };209}210211return { username: 'kieferrm', isNew: false };212}213214interface CommitHandleLookup {215username: string;216isNew: boolean;217}218219220async function getHandleForCommit(commit: string | undefined, cache: CommitHandleCache): Promise<CommitHandleLookup | undefined> {221if (!commit) {222return undefined;223}224225if (cache[commit]) {226return { username: cache[commit], isNew: false };227}228229if (failedHandleCommits.has(commit)) {230return undefined;231}232233let login: string | undefined;234const env = {235...process.env,236GH_PAGER: 'cat',237GH_PROMPT_DISABLED: '1'238};239240const response = spawnSync('gh', ['api', `/repos/${owner}/${repo}/commits/${commit}`], {241cwd: repoRoot,242encoding: 'utf8',243env244});245246if (response.status === 0 && response.stdout) {247try {248const data = JSON.parse(response.stdout);249login = data.author?.login ?? data.committer?.login ?? data.commit?.author?.name;250} catch (error) {251console.warn(`Failed to parse GitHub API response for commit ${commit}`);252}253} else if (response.status !== 0) {254const stderr = typeof response.stderr === 'string' ? response.stderr.trim() : '';255console.warn(`gh api commit ${commit} exited with code ${response.status}${stderr ? `: ${stderr}` : ''}`);256}257258if (!login) {259login = getHandleFromLocalGit(commit);260}261262if (!login) {263failedHandleCommits.add(commit);264console.warn(`Unable to resolve GitHub handle for commit ${commit}`);265return undefined;266}267268const normalized = normalizeHandle(login);269cache[commit] = normalized;270return { username: normalized, isNew: true };271}272273function getHandleFromLocalGit(commit: string): string | undefined {274try {275const email = runGit(['show', '-s', '--format=%ae', commit]);276const handleFromEmail = extractHandleFromEmail(email);277if (handleFromEmail) {278return handleFromEmail;279}280const author = runGit(['show', '-s', '--format=%an', commit]);281return normalizePossibleHandle(author);282} catch {283return undefined;284}285}286287function extractHandleFromEmail(email: string): string | undefined {288const noreplyPattern = /^(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com$/;289const match = email.match(noreplyPattern);290if (match) {291return match[1];292}293return undefined;294}295296function normalizePossibleHandle(name: string): string | undefined {297const normalized = name.trim();298if (!normalized || /\s/.test(normalized)) {299return undefined;300}301return normalized;302}303304function normalizeHandle(handle: string): string {305return handle.startsWith('@') ? handle.substring(1) : handle;306}307308function extractCommitHash(blameOutput: string): string | undefined {309const firstLine = blameOutput.split('\n')[0]?.trim();310if (!firstLine) {311return undefined;312}313314const commit = firstLine.split(' ')[0];315if (!commit || /^[0]+$/.test(commit)) {316return undefined;317}318319return commit.startsWith('^') ? commit.substring(1) : commit;320}321322function formatReportLine(message: ESLintMessage, handle: string): string {323const rule = message.ruleId ?? '';324const column = message.column ?? 0;325const line = `${message.line}:${column}`;326const components = [` - [ ] ${line}`];327if (rule) {328components.push(rule);329}330if (handle) {331components.push(`@${handle}`);332}333return components.join(' ');334}335336async function updateEslintIgnores(files: string[]): Promise<void> {337if (!files.length) {338return;339}340341const configPath = path.join(repoRoot, 'ignores.md');342const nextContent = files.map(file => `'${file}'`).join(',\n');343await fs.writeFile(configPath, nextContent, 'utf8');344}345346function toPosixPath(input: string): string {347return input.split(path.sep).join('/');348}349350function runGit(args: string[]): string {351return runGitCommand(repoRoot, args);352}353354async function fileExists(filePath: string): Promise<boolean> {355try {356await fs.stat(filePath);357return true;358} catch {359return false;360}361}362363async function resolveHandleFromAlternateRepo(file: string): Promise<string | undefined> {364if (alternateRepoHandleCache.has(file)) {365const cached = alternateRepoHandleCache.get(file);366return cached ?? undefined;367}368369if (!(await hasAlternateRepo())) {370alternateRepoHandleCache.set(file, null);371return undefined;372}373374const relativeFile = file.startsWith('./') ? file.substring(2) : file;375const fileForGit = relativeFile.split('/').join(path.sep);376const absolutePath = path.join(alternateRepoRoot, fileForGit);377378if (!(await fileExists(absolutePath))) {379alternateRepoHandleCache.set(file, null);380return undefined;381}382383try {384const lastCommit = runGitCommand(alternateRepoRoot, ['log', '-n', '1', '--pretty=format:%H', '--', fileForGit]);385if (!lastCommit) {386alternateRepoHandleCache.set(file, null);387return undefined;388}389390const email = runGitCommand(alternateRepoRoot, ['show', '-s', '--format=%ae', lastCommit]);391const handleFromEmail = extractHandleFromEmail(email);392let resolvedHandle = handleFromEmail ? normalizeHandle(handleFromEmail) : undefined;393394if (!resolvedHandle) {395const author = runGitCommand(alternateRepoRoot, ['show', '-s', '--format=%an', lastCommit]);396const possibleHandle = normalizePossibleHandle(author);397if (possibleHandle) {398resolvedHandle = normalizeHandle(possibleHandle);399}400}401402if (resolvedHandle) {403alternateRepoHandleCache.set(file, resolvedHandle);404return resolvedHandle;405}406} catch (error) {407console.warn(`Failed to resolve alternate repo handle for ${file}${error instanceof Error ? `: ${error.message}` : ''}`);408}409410alternateRepoHandleCache.set(file, null);411return undefined;412}413414async function hasAlternateRepo(): Promise<boolean> {415if (alternateRepoAvailability !== undefined) {416return alternateRepoAvailability;417}418419try {420const stats = await fs.stat(alternateRepoRoot);421alternateRepoAvailability = stats.isDirectory();422} catch {423alternateRepoAvailability = false;424}425426return alternateRepoAvailability;427}428429function runGitCommand(cwd: string, args: string[]): string {430const result = spawnSync('git', args, {431cwd,432encoding: 'utf8'433});434435if (result.status !== 0) {436throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);437}438439return (result.stdout ?? '').trim();440}441442443