Skip to content

Commit 50b46e1

Browse files
committed
feat: integrate AI tooling into ng-dev
Integrates the AI tooling prototype into `ng-dev`.
1 parent f028906 commit 50b46e1

File tree

10 files changed

+584
-1
lines changed

10 files changed

+584
-1
lines changed

ng-dev/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ ts_library(
2222
"//ng-dev:__subpackages__",
2323
],
2424
deps = [
25+
"//ng-dev/ai",
2526
"//ng-dev/auth",
2627
"//ng-dev/caretaker",
2728
"//ng-dev/commit-message",

ng-dev/ai/BUILD.bazel

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
ts_library(
4+
name = "ai",
5+
srcs = glob([
6+
"**/*.ts",
7+
]),
8+
visibility = ["//ng-dev:__subpackages__"],
9+
deps = [
10+
"//ng-dev/utils",
11+
"@npm//@google/genai",
12+
"@npm//@types/cli-progress",
13+
"@npm//@types/node",
14+
"@npm//@types/yargs",
15+
"@npm//cli-progress",
16+
"@npm//fast-glob",
17+
"@npm//yargs",
18+
],
19+
)

ng-dev/ai/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {Argv} from 'yargs';
9+
import {MigrateModule} from './migrate.js';
10+
import {FixModule} from './fix.js';
11+
12+
/** Build the parser for the AI commands. */
13+
export function buildAiParser(localYargs: Argv) {
14+
return localYargs.command(MigrateModule).command(FixModule);
15+
}

ng-dev/ai/consts.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/** Default model to use for AI-based scripts. */
10+
export const DEFAULT_MODEL = 'gemini-2.5-flash';
11+
12+
/** Default temperature for AI-based scripts. */
13+
export const DEFAULT_TEMPERATURE = 0.1;
14+
15+
/** Gets the API key and asserts that it is defined. */
16+
export function getApiKey(): string {
17+
const key = process.env.GEMINI_API_KEY;
18+
19+
if (!key) {
20+
console.error(
21+
[
22+
'No API key configured. A Gemini API key must be set as the `GEMINI_API_KEY` environment variable.',
23+
'For internal users, see go/aistudio-apikey',
24+
].join('\n'),
25+
);
26+
process.exit(1);
27+
}
28+
29+
return key;
30+
}

ng-dev/ai/fix.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {createPartFromUri, FileState, GoogleGenAI, Part} from '@google/genai';
10+
import {setTimeout} from 'node:timers/promises';
11+
import {readFile, writeFile} from 'node:fs/promises';
12+
import {basename} from 'node:path';
13+
import glob from 'fast-glob';
14+
import assert from 'node:assert';
15+
import {Bar} from 'cli-progress';
16+
import {Argv, Arguments, CommandModule} from 'yargs';
17+
import {randomUUID} from 'node:crypto';
18+
import {DEFAULT_MODEL, DEFAULT_TEMPERATURE, getApiKey} from './consts.js';
19+
import {Spinner} from '../utils/spinner.js';
20+
21+
/** Command line options. */
22+
export interface Options {
23+
/** Files that the fix should apply to. */
24+
files: string[];
25+
26+
/** Error message(s) to be resolved. */
27+
error: string;
28+
29+
/** Model that should be used to apply the prompt. */
30+
model: string;
31+
32+
/** Temperature for the model. */
33+
temperature: number;
34+
}
35+
36+
interface FixedFileContent {
37+
filePath: string;
38+
content: string;
39+
}
40+
41+
/** Yargs command builder for the command. */
42+
function builder(argv: Argv): Argv<Options> {
43+
return argv
44+
.positional('files', {
45+
description: `One or more glob patterns to find target files (e.g., 'src/**/*.ts' 'test/**/*.ts').`,
46+
type: 'string',
47+
array: true,
48+
demandOption: true,
49+
})
50+
.option('error', {
51+
alias: 'e',
52+
description: 'Full error description from the build process',
53+
type: 'string',
54+
demandOption: true,
55+
})
56+
.option('model', {
57+
type: 'string',
58+
alias: 'm',
59+
description: 'Model to use for the migration',
60+
default: DEFAULT_MODEL,
61+
})
62+
.option('temperature', {
63+
type: 'number',
64+
alias: 't',
65+
default: DEFAULT_TEMPERATURE,
66+
description: 'Temperature for the model. Lower temperature reduces randomness/creativity',
67+
});
68+
}
69+
70+
/** Yargs command handler for the command. */
71+
async function handler(options: Arguments<Options>) {
72+
const fixedContents = await fixFilesWithAI(
73+
options.files,
74+
options.error,
75+
options.model,
76+
options.temperature,
77+
);
78+
console.log('\n--- AI Suggested Fixes Summary ---');
79+
if (fixedContents.length === 0) {
80+
console.log(
81+
'No files were fixed or found matching the pattern. Check your glob pattern and check whether the files exist.',
82+
);
83+
84+
return;
85+
}
86+
87+
console.log('Updated files:');
88+
const writeTasks = fixedContents.map(({filePath, content}) =>
89+
writeFile(filePath, content).then(() => console.log(` - ${filePath}`)),
90+
);
91+
await Promise.all(writeTasks);
92+
}
93+
94+
async function fixFilesWithAI(
95+
globPatterns: string[],
96+
errorDescription: string,
97+
model: string,
98+
temperature: number,
99+
): Promise<FixedFileContent[]> {
100+
const filePaths = await glob(globPatterns, {
101+
onlyFiles: true,
102+
absolute: false,
103+
});
104+
105+
if (filePaths.length === 0) {
106+
console.error(`No files found matching the patterns: ${JSON.stringify(globPatterns)}.`);
107+
return [];
108+
}
109+
110+
const ai = new GoogleGenAI({vertexai: false, apiKey: getApiKey()});
111+
let uploadedFileNames: string[] = [];
112+
113+
const progressBar = new Bar({
114+
format: `{step} [{bar}] ETA: {eta}s | {value}/{total} files`,
115+
clearOnComplete: true,
116+
});
117+
118+
try {
119+
const {
120+
fileNameMap,
121+
partsForGeneration,
122+
uploadedFileNames: uploadedFiles,
123+
} = await uploadFiles(ai, filePaths, progressBar);
124+
125+
uploadedFileNames = uploadedFiles;
126+
127+
const spinner = new Spinner('AI is analyzing the files and generating potential fixes...');
128+
const response = await ai.models.generateContent({
129+
model,
130+
contents: [{text: generatePrompt(errorDescription, fileNameMap)}, ...partsForGeneration],
131+
config: {
132+
responseMimeType: 'application/json',
133+
candidateCount: 1,
134+
maxOutputTokens: Infinity,
135+
temperature,
136+
},
137+
});
138+
139+
const responseText = response.text;
140+
if (!responseText) {
141+
spinner.failure(`AI returned an empty response.`);
142+
return [];
143+
}
144+
145+
const fixes = JSON.parse(responseText) as FixedFileContent[];
146+
147+
if (!Array.isArray(fixes)) {
148+
throw new Error('AI response is not a JSON array.');
149+
}
150+
151+
spinner.complete();
152+
return fixes;
153+
} finally {
154+
if (uploadedFileNames.length) {
155+
progressBar.start(uploadedFileNames.length, 0, {
156+
step: 'Deleting temporary uploaded files',
157+
});
158+
const deleteTasks = uploadedFileNames.map((name) => {
159+
return ai.files
160+
.delete({name})
161+
.catch((error) =>
162+
console.warn(`WARNING: Failed to delete temporary file ${name}:`, error),
163+
)
164+
.finally(() => progressBar.increment());
165+
});
166+
167+
await Promise.allSettled(deleteTasks).finally(() => progressBar.stop());
168+
}
169+
}
170+
}
171+
172+
async function uploadFiles(
173+
ai: GoogleGenAI,
174+
filePaths: string[],
175+
progressBar: Bar,
176+
): Promise<{
177+
uploadedFileNames: string[];
178+
partsForGeneration: Part[];
179+
fileNameMap: Map<string, string>;
180+
}> {
181+
const uploadedFileNames: string[] = [];
182+
const partsForGeneration: Part[] = [];
183+
const fileNameMap = new Map<string, string>();
184+
185+
progressBar.start(filePaths.length, 0, {step: 'Uploading files'});
186+
187+
const uploadPromises = filePaths.map(async (filePath) => {
188+
try {
189+
const uploadedFile = await ai.files.upload({
190+
file: new Blob([await readFile(filePath, {encoding: 'utf8'})], {
191+
type: 'text/plain',
192+
}),
193+
config: {
194+
displayName: `fix_request_${basename(filePath)}_${randomUUID()}`,
195+
},
196+
});
197+
198+
assert(uploadedFile.name, 'File name cannot be undefined after upload.');
199+
200+
let getFile = await ai.files.get({name: uploadedFile.name});
201+
while (getFile.state === FileState.PROCESSING) {
202+
await setTimeout(500); // Wait for 500ms before re-checking
203+
getFile = await ai.files.get({name: uploadedFile.name});
204+
}
205+
206+
if (getFile.state === FileState.FAILED) {
207+
throw new Error(`File processing failed on API for ${filePath}. Skipping this file.`);
208+
}
209+
210+
if (getFile.uri && getFile.mimeType) {
211+
const filePart = createPartFromUri(getFile.uri, getFile.mimeType);
212+
partsForGeneration.push(filePart);
213+
fileNameMap.set(filePath, uploadedFile.name);
214+
progressBar.increment();
215+
return uploadedFile.name; // Return the name on success
216+
} else {
217+
throw new Error(
218+
`Uploaded file for ${filePath} is missing URI or MIME type after processing. Skipping.`,
219+
);
220+
}
221+
} catch (error: any) {
222+
console.error(`Error uploading or processing file ${filePath}: ${error.message}`);
223+
return null; // Indicate failure for this specific file
224+
}
225+
});
226+
227+
const results = await Promise.allSettled(uploadPromises).finally(() => progressBar.stop());
228+
229+
for (const result of results) {
230+
if (result.status === 'fulfilled' && result.value !== null) {
231+
uploadedFileNames.push(result.value);
232+
}
233+
}
234+
235+
return {uploadedFileNames, fileNameMap, partsForGeneration};
236+
}
237+
238+
function generatePrompt(errorDescription: string, fileNameMap: Map<string, string>): string {
239+
return `
240+
You are a highly skilled software engineer, specializing in Bazel, Starlark, Python, Angular, JavaScript,
241+
TypeScript, and everything related.
242+
The following files are part of a build process that failed with the error:
243+
\`\`\`
244+
${errorDescription}
245+
\`\`\`
246+
Please analyze the content of EACH provided file and suggest modifications to resolve the issue.
247+
248+
Your response MUST be a JSON array of objects. Each object in the array MUST have two properties:
249+
'filePath' (the full path from the mappings provided.) and 'content' (the complete corrected content of that file).
250+
DO NOT include any additional text, non modified files, commentary, or markdown outside the JSON array.
251+
For example:
252+
[
253+
{"filePath": "/full-path-from-mappings/file1.txt", "content": "Corrected content for file1."},
254+
{"filePath": "/full-path-from-mappings/file2.js", "content": "console.log('Fixed JS');"}
255+
]
256+
257+
IMPORTANT: The input files are mapped as follows: ${Array.from(fileNameMap.entries())}
258+
`;
259+
}
260+
261+
/** CLI command module. */
262+
export const FixModule: CommandModule<{}, Options> = {
263+
builder,
264+
handler,
265+
command: 'fix <files..>',
266+
describe: 'Fixes errors from the specified error output',
267+
};

0 commit comments

Comments
 (0)