From 3eca82aae5e6917dd1261b42394aa1b62491697e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 23 Jun 2025 10:04:04 +0200 Subject: [PATCH] feat(ng-dev): integrate AI tooling into ng-dev Integrates the AI tooling prototype into `ng-dev`. --- ng-dev/BUILD.bazel | 3 +- ng-dev/ai/BUILD.bazel | 19 +++ ng-dev/ai/cli.ts | 15 +++ ng-dev/ai/consts.ts | 16 +++ ng-dev/ai/fix.ts | 286 ++++++++++++++++++++++++++++++++++++++++++ ng-dev/ai/migrate.ts | 242 +++++++++++++++++++++++++++++++++++ ng-dev/cli.ts | 2 + ng-dev/package.json | 1 + package.json | 1 + yarn.lock | 27 +++- 10 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 ng-dev/ai/BUILD.bazel create mode 100644 ng-dev/ai/cli.ts create mode 100644 ng-dev/ai/consts.ts create mode 100644 ng-dev/ai/fix.ts create mode 100644 ng-dev/ai/migrate.ts diff --git a/ng-dev/BUILD.bazel b/ng-dev/BUILD.bazel index 9c5cbf1cf..84e12c3e1 100644 --- a/ng-dev/BUILD.bazel +++ b/ng-dev/BUILD.bazel @@ -1,6 +1,6 @@ load("//:package.bzl", "NPM_PACKAGE_SUBSTITUTIONS") -load("//tools:defaults.bzl", "esbuild_esm_bundle", "pkg_npm", "ts_library") load("//bazel:extract_types.bzl", "extract_types") +load("//tools:defaults.bzl", "esbuild_esm_bundle", "pkg_npm", "ts_library") NG_DEV_EXTERNALS = [ # `typescript` is external because we want the project to provide a TypeScript version. @@ -22,6 +22,7 @@ ts_library( "//ng-dev:__subpackages__", ], deps = [ + "//ng-dev/ai", "//ng-dev/auth", "//ng-dev/caretaker", "//ng-dev/commit-message", diff --git a/ng-dev/ai/BUILD.bazel b/ng-dev/ai/BUILD.bazel new file mode 100644 index 000000000..43bdd7dac --- /dev/null +++ b/ng-dev/ai/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "ai", + srcs = glob([ + "**/*.ts", + ]), + visibility = ["//ng-dev:__subpackages__"], + deps = [ + "//ng-dev/utils", + "@npm//@google/genai", + "@npm//@types/cli-progress", + "@npm//@types/node", + "@npm//@types/yargs", + "@npm//cli-progress", + "@npm//fast-glob", + "@npm//yargs", + ], +) diff --git a/ng-dev/ai/cli.ts b/ng-dev/ai/cli.ts new file mode 100644 index 000000000..78b97bd21 --- /dev/null +++ b/ng-dev/ai/cli.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Argv} from 'yargs'; +import {MigrateModule} from './migrate.js'; +import {FixModule} from './fix.js'; + +/** Build the parser for the AI commands. */ +export function buildAiParser(localYargs: Argv) { + return localYargs.command(MigrateModule).command(FixModule); +} diff --git a/ng-dev/ai/consts.ts b/ng-dev/ai/consts.ts new file mode 100644 index 000000000..43a83d29f --- /dev/null +++ b/ng-dev/ai/consts.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Default model to use for AI-based scripts. */ +export const DEFAULT_MODEL = 'gemini-2.5-flash'; + +/** Default temperature for AI-based scripts. */ +export const DEFAULT_TEMPERATURE = 0.1; + +/** Default API key to use when running AI-based scripts. */ +export const DEFAULT_API_KEY = process.env.GEMINI_API_KEY; diff --git a/ng-dev/ai/fix.ts b/ng-dev/ai/fix.ts new file mode 100644 index 000000000..b945c1dbd --- /dev/null +++ b/ng-dev/ai/fix.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {createPartFromUri, FileState, GoogleGenAI, Part} from '@google/genai'; +import {setTimeout} from 'node:timers/promises'; +import {readFile, writeFile} from 'node:fs/promises'; +import {basename} from 'node:path'; +import glob from 'fast-glob'; +import assert from 'node:assert'; +import {Bar} from 'cli-progress'; +import {Argv, Arguments, CommandModule} from 'yargs'; +import {randomUUID} from 'node:crypto'; +import {DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_API_KEY} from './consts.js'; +import {Spinner} from '../utils/spinner.js'; +import {Log} from '../utils/logging.js'; + +/** Command line options. */ +export interface Options { + /** Files that the fix should apply to. */ + files: string[]; + + /** Error message(s) to be resolved. */ + error: string; + + /** Model that should be used to apply the prompt. */ + model: string; + + /** Temperature for the model. */ + temperature: number; + + /** API key to use when making requests. */ + apiKey?: string; +} + +interface FixedFileContent { + filePath: string; + content: string; +} + +/** Yargs command builder for the command. */ +function builder(argv: Argv): Argv { + return argv + .positional('files', { + description: `One or more glob patterns to find target files (e.g., 'src/**/*.ts' 'test/**/*.ts').`, + type: 'string', + array: true, + demandOption: true, + }) + .option('error', { + alias: 'e', + description: 'Full error description from the build process', + type: 'string', + demandOption: true, + }) + .option('model', { + type: 'string', + alias: 'm', + description: 'Model to use for the migration', + default: DEFAULT_MODEL, + }) + .option('temperature', { + type: 'number', + alias: 't', + default: DEFAULT_TEMPERATURE, + description: 'Temperature for the model. Lower temperature reduces randomness/creativity', + }) + .option('apiKey', { + type: 'string', + alias: 'a', + default: DEFAULT_API_KEY, + description: 'API key used when making calls to the Gemini API', + }); +} + +/** Yargs command handler for the command. */ +async function handler(options: Arguments) { + assert( + options.apiKey, + [ + 'No API key configured. A Gemini API key must be set as the `GEMINI_API_KEY` environment ' + + 'variable, or passed in using the `--api-key` flag.', + 'For internal users, see go/aistudio-apikey', + ].join('\n'), + ); + + const fixedContents = await fixFilesWithAI( + options.apiKey, + options.files, + options.error, + options.model, + options.temperature, + ); + Log.info('\n--- AI Suggested Fixes Summary ---'); + if (fixedContents.length === 0) { + Log.info( + 'No files were fixed or found matching the pattern. Check your glob pattern and check whether the files exist.', + ); + + return; + } + + Log.info('Updated files:'); + const writeTasks = fixedContents.map(({filePath, content}) => + writeFile(filePath, content).then(() => Log.info(` - ${filePath}`)), + ); + await Promise.all(writeTasks); +} + +async function fixFilesWithAI( + apiKey: string, + globPatterns: string[], + errorDescription: string, + model: string, + temperature: number, +): Promise { + const filePaths = await glob(globPatterns, { + onlyFiles: true, + absolute: false, + }); + + if (filePaths.length === 0) { + Log.error(`No files found matching the patterns: ${JSON.stringify(globPatterns, null, 2)}.`); + return []; + } + + const ai = new GoogleGenAI({vertexai: false, apiKey}); + let uploadedFileNames: string[] = []; + + const progressBar = new Bar({ + format: `{step} [{bar}] ETA: {eta}s | {value}/{total} files`, + clearOnComplete: true, + }); + + try { + const { + fileNameMap, + partsForGeneration, + uploadedFileNames: uploadedFiles, + } = await uploadFiles(ai, filePaths, progressBar); + + uploadedFileNames = uploadedFiles; + + const spinner = new Spinner('AI is analyzing the files and generating potential fixes...'); + const response = await ai.models.generateContent({ + model, + contents: [{text: generatePrompt(errorDescription, fileNameMap)}, ...partsForGeneration], + config: { + responseMimeType: 'application/json', + candidateCount: 1, + maxOutputTokens: Infinity, + temperature, + }, + }); + + const responseText = response.text; + if (!responseText) { + spinner.failure(`AI returned an empty response.`); + return []; + } + + const fixes = JSON.parse(responseText) as FixedFileContent[]; + + if (!Array.isArray(fixes)) { + throw new Error('AI response is not a JSON array.'); + } + + spinner.complete(); + return fixes; + } finally { + if (uploadedFileNames.length) { + progressBar.start(uploadedFileNames.length, 0, { + step: 'Deleting temporary uploaded files', + }); + const deleteTasks = uploadedFileNames.map((name) => { + return ai.files + .delete({name}) + .catch((error) => Log.warn(`WARNING: Failed to delete temporary file ${name}:`, error)) + .finally(() => progressBar.increment()); + }); + + await Promise.allSettled(deleteTasks).finally(() => progressBar.stop()); + } + } +} + +async function uploadFiles( + ai: GoogleGenAI, + filePaths: string[], + progressBar: Bar, +): Promise<{ + uploadedFileNames: string[]; + partsForGeneration: Part[]; + fileNameMap: Map; +}> { + const uploadedFileNames: string[] = []; + const partsForGeneration: Part[] = []; + const fileNameMap = new Map(); + + progressBar.start(filePaths.length, 0, {step: 'Uploading files'}); + + const uploadPromises = filePaths.map(async (filePath) => { + try { + const uploadedFile = await ai.files.upload({ + file: new Blob([await readFile(filePath, {encoding: 'utf8'})], { + type: 'text/plain', + }), + config: { + displayName: `fix_request_${basename(filePath)}_${randomUUID()}`, + }, + }); + + assert(uploadedFile.name, 'File name cannot be undefined after upload.'); + + let getFile = await ai.files.get({name: uploadedFile.name}); + while (getFile.state === FileState.PROCESSING) { + await setTimeout(500); // Wait for 500ms before re-checking + getFile = await ai.files.get({name: uploadedFile.name}); + } + + if (getFile.state === FileState.FAILED) { + throw new Error(`File processing failed on API for ${filePath}. Skipping this file.`); + } + + if (getFile.uri && getFile.mimeType) { + const filePart = createPartFromUri(getFile.uri, getFile.mimeType); + partsForGeneration.push(filePart); + fileNameMap.set(filePath, uploadedFile.name); + progressBar.increment(); + return uploadedFile.name; // Return the name on success + } else { + throw new Error( + `Uploaded file for ${filePath} is missing URI or MIME type after processing. Skipping.`, + ); + } + } catch (error: any) { + Log.error(`Error uploading or processing file ${filePath}: ${error.message}`); + return null; // Indicate failure for this specific file + } + }); + + const results = await Promise.allSettled(uploadPromises).finally(() => progressBar.stop()); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value !== null) { + uploadedFileNames.push(result.value); + } + } + + return {uploadedFileNames, fileNameMap, partsForGeneration}; +} + +function generatePrompt(errorDescription: string, fileNameMap: Map): string { + return ` + You are a highly skilled software engineer, specializing in Bazel, Starlark, Python, Angular, JavaScript, + TypeScript, and everything related. + The following files are part of a build process that failed with the error: + \`\`\` + ${errorDescription} + \`\`\` + Please analyze the content of EACH provided file and suggest modifications to resolve the issue. + + Your response MUST be a JSON array of objects. Each object in the array MUST have two properties: + 'filePath' (the full path from the mappings provided.) and 'content' (the complete corrected content of that file). + DO NOT include any additional text, non modified files, commentary, or markdown outside the JSON array. + For example: + [ + {"filePath": "/full-path-from-mappings/file1.txt", "content": "Corrected content for file1."}, + {"filePath": "/full-path-from-mappings/file2.js", "content": "console.log('Fixed JS');"} + ] + + IMPORTANT: The input files are mapped as follows: ${Array.from(fileNameMap.entries())} +`; +} + +/** CLI command module. */ +export const FixModule: CommandModule<{}, Options> = { + builder, + handler, + command: 'fix ', + describe: 'Fixes errors from the specified error output', +}; diff --git a/ng-dev/ai/migrate.ts b/ng-dev/ai/migrate.ts new file mode 100644 index 000000000..147324157 --- /dev/null +++ b/ng-dev/ai/migrate.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {GoogleGenAI} from '@google/genai'; +import {Argv, Arguments, CommandModule} from 'yargs'; +import {readFile, writeFile} from 'fs/promises'; +import {SingleBar, Presets} from 'cli-progress'; +import {DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_API_KEY} from './consts.js'; +import assert from 'node:assert'; +import {Log} from '../utils/logging.js'; +import glob from 'fast-glob'; + +/** Command line options. */ +export interface Options { + /** Prompt that should be applied. */ + prompt: string; + + /** Glob of files that the prompt should apply to. */ + files: string; + + /** Model that should be used to apply the prompt. */ + model: string; + + /** Temperature for the model. */ + temperature: number; + + /** Maximum number of concurrent API requests. */ + maxConcurrency: number; + + /** API key to use when making requests. */ + apiKey?: string; +} + +/** Yargs command builder for the command. */ +function builder(argv: Argv): Argv { + return argv + .option('prompt', { + type: 'string', + alias: 'p', + description: 'Path to the file containg the prompt that will be run', + demandOption: true, + }) + .option('files', { + type: 'string', + alias: 'f', + description: 'Glob for the files that should be migrated', + demandOption: true, + }) + .option('model', { + type: 'string', + alias: 'm', + description: 'Model to use for the migration', + default: DEFAULT_MODEL, + }) + .option('maxConcurrency', { + type: 'number', + default: 25, + description: + 'Maximum number of concurrent requests to the API. Higher numbers may hit usages limits', + }) + .option('temperature', { + type: 'number', + alias: 't', + default: DEFAULT_TEMPERATURE, + description: 'Temperature for the model. Lower temperature reduces randomness/creativity', + }) + .option('apiKey', { + type: 'string', + alias: 'a', + default: DEFAULT_API_KEY, + description: 'API key used when making calls to the Gemini API', + }); +} + +/** Yargs command handler for the command. */ +async function handler(options: Arguments) { + assert( + options.apiKey, + [ + 'No API key configured. A Gemini API key must be set as the `GEMINI_API_KEY` environment ' + + 'variable, or passed in using the `--api-key` flag.', + 'For internal users, see go/aistudio-apikey', + ].join('\n'), + ); + + const [files, prompt] = await Promise.all([ + glob([options.files]), + readFile(options.prompt, 'utf-8'), + ]); + + if (files.length === 0) { + Log.error(`No files matched the pattern "${options.files}"`); + process.exit(1); + } + + const ai = new GoogleGenAI({apiKey: options.apiKey}); + const progressBar = new SingleBar({}, Presets.shades_grey); + const failures: {name: string; error: string}[] = []; + const running = new Set>(); + + Log.info( + [ + `Applying prompt from ${options.prompt} to ${files.length} files(s).`, + `Using model ${options.model} with a temperature of ${options.temperature}.`, + '', // Extra new line at the end. + ].join('\n'), + ); + progressBar.start(files.length, 0); + + // Kicks off the maximum number of concurrent requests and ensures that as many requests as + // possible are running at the same time. This is preferrable to chunking, because it allows + // the requests to keep running even if there's one which is taking a long time to resolve. + while (files.length > 0 || running.size > 0) { + // Fill up to maxConcurrency + while (files.length > 0 && running.size < options.maxConcurrency) { + const file = files.shift()!; + const task = processFile(file).finally(() => running.delete(task)); + running.add(task); + } + + // Wait for any task to finish + if (running.size > 0) { + await Promise.race(running); + } + } + + progressBar.stop(); + + for (const {name, error} of failures) { + Log.info('-------------------------------------'); + Log.info(`${name} failed to migrate:`); + Log.info(error); + } + + Log.info(`\nDone 🎉`); + + if (failures.length > 0) { + Log.info(`${failures.length} file(s) failed. See logs above for more information.`); + } + + async function processFile(file: string): Promise { + try { + const content = await readFile(file, 'utf-8'); + const result = await applyPrompt(ai, options.model, options.temperature, content, prompt); + await writeFile(file, result); + } catch (e) { + failures.push({name: file, error: (e as Error).toString()}); + } finally { + progressBar.increment(); + } + } +} + +/** + * Applies a prompt to a specific file's content. + * @param ai Instance of the GenAI SDK. + * @param model Model to use for the prompt. + * @param temperature Temperature for the promp. + * @param content Content of the file. + * @param prompt Prompt to be run. + */ +async function applyPrompt( + ai: GoogleGenAI, + model: string, + temperature: number, + content: string, + prompt: string, +): Promise { + // The schema ensures that the API returns a response in the format that we expect. + const responseSchema = { + type: 'object', + properties: { + content: {type: 'string', description: 'Changed content of the file'}, + }, + required: ['content'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }; + + // Note that technically we can batch multiple files into a single `generateContent` call. + // We don't do it, because it increases the risk that we'll hit the output token limit which + // can corrupt the entire response. This way one file failing won't break the entire run. + const response = await ai.models.generateContent({ + model, + contents: [{text: prompt}, {text: content}], + config: { + responseMimeType: 'application/json', + responseSchema, + temperature, + // We need as many output tokens as we can get. + maxOutputTokens: Infinity, + // We know that we'll only use one candidate so we can save some processing. + candidateCount: 1, + // Guide the LLM towards following our schema. + systemInstruction: + `Return output following the structured output schema. ` + + `Return an object containing the new contents of the changed file.`, + }, + }); + + const text = response.text; + + if (!text) { + throw new Error(`No response from the API. Response:\n` + JSON.stringify(response, null, 2)); + } + + let parsed: {content?: string}; + + try { + parsed = JSON.parse(text) as {content: string}; + } catch { + throw new Error( + 'Failed to parse result as JSON. This can happen if if maximum output ' + + 'token size has been reached. Try using a different model. ' + + 'Response:\n' + + JSON.stringify(response, null, 2), + ); + } + + if (!parsed.content) { + throw new Error( + 'Could not find content in parsed API response. This can indicate a problem ' + + 'with the request parameters. Parsed response:\n' + + JSON.stringify(parsed, null, 2), + ); + } + + return parsed.content; +} + +/** CLI command module. */ +export const MigrateModule: CommandModule<{}, Options> = { + builder, + handler, + command: 'migrate', + describe: 'Apply a prompt-based AI migration over a set of files', +}; diff --git a/ng-dev/cli.ts b/ng-dev/cli.ts index 10fdd29f4..65103a012 100644 --- a/ng-dev/cli.ts +++ b/ng-dev/cli.ts @@ -21,6 +21,7 @@ import {tsCircularDependenciesBuilder} from './ts-circular-dependencies/index.js import {captureLogOutputForCommand} from './utils/logging.js'; import {buildAuthParser} from './auth/cli.js'; import {buildPerfParser} from './perf/cli.js'; +import {buildAiParser} from './ai/cli.js'; import {Argv} from 'yargs'; runParserWithCompletedFunctions((yargs: Argv) => { @@ -40,6 +41,7 @@ runParserWithCompletedFunctions((yargs: Argv) => { .command('misc ', '', buildMiscParser) .command('ngbot ', false, buildNgbotParser) .command('perf ', '', buildPerfParser) + .command('ai ', '', buildAiParser) .wrap(120) .strict(); }); diff --git a/ng-dev/package.json b/ng-dev/package.json index 7ba3dd892..98ea21432 100644 --- a/ng-dev/package.json +++ b/ng-dev/package.json @@ -15,6 +15,7 @@ } }, "dependencies": { + "@google/genai": "^1.4.0", "@google-cloud/spanner": "8.0.0", "@octokit/rest": "22.0.0", "@types/semver": "^7.3.6", diff --git a/package.json b/package.json index bf93aa87f..76ba1b1b6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@bazel/runfiles": "6.3.1", "@bazel/terser": "5.8.1", "@bazel/typescript": "5.8.1", + "@google/genai": "^1.4.0", "@microsoft/api-extractor": "7.52.8", "@types/browser-sync": "^2.26.3", "@types/minimatch": "^5.1.2", diff --git a/yarn.lock b/yarn.lock index 36eec81f5..80f3d2f49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -259,6 +259,7 @@ __metadata: "@google-cloud/firestore": "npm:^7.0.0" "@google-cloud/spanner": "npm:8.0.0" "@google-cloud/storage": "npm:^7.0.0" + "@google/genai": "npm:^1.4.0" "@inquirer/prompts": "npm:^7.0.0" "@inquirer/type": "npm:^3.0.0" "@microsoft/api-extractor": "npm:7.52.8" @@ -2106,6 +2107,23 @@ __metadata: languageName: node linkType: hard +"@google/genai@npm:^1.4.0": + version: 1.5.1 + resolution: "@google/genai@npm:1.5.1" + dependencies: + google-auth-library: "npm:^9.14.2" + ws: "npm:^8.18.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.22.4" + peerDependencies: + "@modelcontextprotocol/sdk": ^1.11.0 + peerDependenciesMeta: + "@modelcontextprotocol/sdk": + optional: true + checksum: 10c0/74fdf85db4999a866c2a5bf38bcfb072e67d09128ce729871a0dc991aa81864e03dc4cfbda8d94aa45ceb70ad2c44c8d68c26aec0ced2b83ad30fe7cd4cf1357 + languageName: node + linkType: hard + "@googleapis/sqladmin@npm:^29.0.0": version: 29.0.0 resolution: "@googleapis/sqladmin@npm:29.0.0" @@ -15908,7 +15926,7 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.24.1, zod-to-json-schema@npm:^3.24.5": +"zod-to-json-schema@npm:^3.22.4, zod-to-json-schema@npm:^3.24.1, zod-to-json-schema@npm:^3.24.5": version: 3.24.5 resolution: "zod-to-json-schema@npm:3.24.5" peerDependencies: @@ -15917,6 +15935,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.22.4": + version: 3.25.67 + resolution: "zod@npm:3.25.67" + checksum: 10c0/80a0cab3033272c4ab9312198081f0c4ea88e9673c059aa36dc32024906363729db54bdb78f3dc9d5529bd1601f74974d5a56c0a23e40c6f04a9270c9ff22336 + languageName: node + linkType: hard + "zod@npm:^3.23.8, zod@npm:^3.24.3": version: 3.25.64 resolution: "zod@npm:3.25.64"