Setting the file. One moment. CI · CI · google-gemini/gemini-cli · Skills Docsscripts/ci.mjs
scripts/ci.mjs
JavaScript·281 lines·9 KB
().
trim
();
13const RUN_ID_OVERRIDE = process.argv[3];
14
15let REPO;
16try {
17 const remoteUrl = execSync('git remote get-url origin').toString().trim();
18 REPO = remoteUrl
19 .replace(/.*github\.com[\/:]/, '')
20 .replace(/\.git$/, '')
21 .trim();
22} catch (e) {
23 REPO = 'google-gemini/gemini-cli';
24}
25
26const FAILED_FILES = new Set();
27
28function runGh(args) {
29 try {
30 return execSync(`gh ${args}`, {
31 stdio: ['ignore', 'pipe', 'ignore'],
32 }).toString();
33 } catch (e) {
34 return null;
35 }
36}
37
38function fetchFailuresViaApi(jobId) {
39 try {
40 const cmd = `gh api repos/${REPO}/actions/jobs/${jobId}/logs | grep -iE " FAIL |❌|ERROR|Lint failed|Build failed|Exception|failed with exit code"`;
41 return execSync(cmd, {
42 stdio: ['ignore', 'pipe', 'ignore'],
43 maxBuffer: 10 * 1024 * 1024,
44 }).toString();
45 } catch (e) {
46 return '';
47 }
48}
49
50function isNoise(line) {
51 const lower = line.toLowerCase();
52 return (
53 lower.includes('* [new branch]') ||
54 lower.includes('npm warn') ||
55 lower.includes('fetching updates') ||
56 lower.includes('node:internal/errors') ||
57 lower.includes('at ') || // Stack traces
58 lower.includes('checkexecsyncerror') ||
59 lower.includes('node_modules')
60 );
61}
62
63function extractTestFile(failureText) {
64 const cleanLine = failureText
65 .replace(/[|#\[\]()]/g, ' ')
66 .replace(/<[^>]*>/g, ' ')
67 .trim();
68 const fileMatch = cleanLine.match(/([\w\/._-]+\.test\.[jt]sx?)/);
69 if (fileMatch) return fileMatch[1];
70 return null;
71}
72
73function generateTestCommand(failedFilesMap) {
74 const workspaceToFiles = new Map();
75 for (const [file, info] of failedFilesMap.entries()) {
76 if (
77 ['Job Error', 'Unknown File', 'Build Error', 'Lint Error'].includes(file)
78 )
79 continue;
80 let workspace = '@google/gemini-cli';
81 let relPath = file;
82 if (file.startsWith('packages/core/')) {
83 workspace = '@google/gemini-cli-core';
84 relPath = file.replace('packages/core/', '');
85 } else if (file.startsWith('packages/cli/')) {
86 workspace = '@google/gemini-cli';
87 relPath = file.replace('packages/cli/', '');
88 }
89 relPath = relPath.replace(/^.*packages\/[^\/]+\//, '');
90 if (!workspaceToFiles.has(workspace))
91 workspaceToFiles.set(workspace, new Set());
92 workspaceToFiles.get(workspace).add(relPath);
93 }
94 const commands = [];
95 for (const [workspace, files] of workspaceToFiles.entries()) {
96 commands.push(`npm test -w ${workspace} -- ${Array.from(files).join(' ')}`);
97 }
98 return commands.join(' && ');
99}
100
101async function monitor() {
102 let targetRunIds = [];
103 if (RUN_ID_OVERRIDE) {
104 targetRunIds = [RUN_ID_OVERRIDE];
105 } else {
106 // 1. Get runs directly associated with the branch
107 const runListOutput = runGh(
108 `run list --branch "${BRANCH}" --limit 10 --json databaseId,status,workflowName,createdAt`,
109 );
110 if (runListOutput) {
111 const runs = JSON.parse(runListOutput);
112 const activeRuns = runs.filter((r) => r.status !== 'completed');
113 if (activeRuns.length > 0) {
114 targetRunIds = activeRuns.map((r) => r.databaseId);
115 } else if (runs.length > 0) {
116 const latestTime = new Date(runs[0].createdAt).getTime();
117 targetRunIds = runs
118 .filter((r) => latestTime - new Date(r.createdAt).getTime() < 60000)
119 .map((r) => r.databaseId);
120 }
121 }
122
123 // 2. Get runs associated with commit statuses (handles chained/indirect runs)
124 try {
125 const headSha = execSync(`git rev-parse "${BRANCH}"`).toString().trim();
126 const statusOutput = runGh(
127 `api repos/${REPO}/commits/${headSha}/status -q '.statuses[] | select(.target_url | contains("actions/runs/")) | .target_url'`,
128 );
129 if (statusOutput) {
130 const statusRunIds = statusOutput
131 .split('\n')
132 .filter(Boolean)
133 .map((url) => {
134 const match = url.match(/actions\/runs\/(\d+)/);
135 return match ? parseInt(match[1], 10) : null;
136 })
137 .filter(Boolean);
138
139 for (const runId of statusRunIds) {
140 if (!targetRunIds.includes(runId)) {
141 targetRunIds.push(runId);
142 }
143 }
144 }
145 } catch (e) {
146 // Ignore if branch/SHA not found or API fails
147 }
148
149 if (targetRunIds.length > 0) {
150 const runNames = [];
151 for (const runId of targetRunIds) {
152 const runInfo = runGh(`run view "${runId}" --json workflowName`);
153 if (runInfo) {
154 runNames.push(JSON.parse(runInfo).workflowName);
155 }
156 }
157 console.log(`Monitoring workflows: ${[...new Set(runNames)].join(', ')}`);
158 }
159 }
160
161 if (targetRunIds.length === 0) {
162 console.log(`No runs found for branch ${BRANCH}.`);
163 process.exit(0);
164 }
165
166 while (true) {
167 let allPassed = 0,
168 allFailed = 0,
169 allRunning = 0,
170 allQueued = 0,
171 totalJobs = 0;
172 let anyRunInProgress = false;
173 const fileToTests = new Map();
174 let failuresFoundInLoop = false;
175
176 for (const runId of targetRunIds) {
177 const runOutput = runGh(
178 `run view "${runId}" --json databaseId,status,conclusion,workflowName`,
179 );
180 if (!runOutput) continue;
181 const run = JSON.parse(runOutput);
182 if (run.status !== 'completed') anyRunInProgress = true;
183
184 const jobsOutput = runGh(`run view "${runId}" --json jobs`);
185 if (jobsOutput) {
186 const { jobs } = JSON.parse(jobsOutput);
187 totalJobs += jobs.length;
188 const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
189 if (failedJobs.length > 0) {
190 failuresFoundInLoop = true;
191 for (const job of failedJobs) {
192 const failures = fetchFailuresViaApi(job.databaseId);
193 if (failures.trim()) {
194 failures.split('\n').forEach((line) => {
195 if (!line.trim() || isNoise(line)) return;
196 const file = extractTestFile(line);
197 const filePath =
198 file ||
199 (line.toLowerCase().includes('lint')
200 ? 'Lint Error'
201 : line.toLowerCase().includes('build')
202 ? 'Build Error'
203 : 'Unknown File');
204 let testName = line;
205 if (line.includes(' > ')) {
206 testName = line.split(' > ').slice(1).join(' > ').trim();
207 }
208 if (!fileToTests.has(filePath))
209 fileToTests.set(filePath, new Set());
210 fileToTests.get(filePath).add(testName);
211 });
212 } else {
213 const step =
214 job.steps?.find((s) => s.conclusion === 'failure')?.name ||
215 'unknown';
216 const category = step.toLowerCase().includes('lint')
217 ? 'Lint Error'
218 : step.toLowerCase().includes('build')
219 ? 'Build Error'
220 : 'Job Error';
221 if (!fileToTests.has(category))
222 fileToTests.set(category, new Set());
223 fileToTests
224 .get(category)
225 .add(`${job.name}: Failed at step "${step}"`);
226 }
227 }
228 }
229 for (const job of jobs) {
230 if (job.status === 'in_progress') allRunning++;
231 else if (job.status === 'queued') allQueued++;
232 else if (job.conclusion === 'success') allPassed++;
233 else if (job.conclusion === 'failure') allFailed++;
234 }
235 }
236 }
237
238 if (failuresFoundInLoop) {
239 console.log(
240 `\n\n❌ Failures detected across ${allFailed} job(s). Stopping monitor...`,
241 );
242 console.log('\n--- Structured Failure Report (Noise Filtered) ---');
243 for (const [file, tests] of fileToTests.entries()) {
244 console.log(`\nCategory/File: ${file}`);
245 // Limit output per file if it's too large
246 const testsArr = Array.from(tests).map((t) =>
247 t.length > 500 ? t.substring(0, 500) + '... [TRUNCATED]' : t,
248 );
249 testsArr.slice(0, 10).forEach((t) => console.log(` - ${t}`));
250 if (testsArr.length > 10)
251 console.log(` ... and ${testsArr.length - 10} more`);
252 }
253 const testCmd = generateTestCommand(fileToTests);
254 if (testCmd) {
255 console.log('\n🚀 Run this to verify fixes:');
256 console.log(testCmd);
257 } else if (
258 Array.from(fileToTests.keys()).some((k) => k.includes('Lint'))
259 ) {
260 console.log('\n🚀 Run this to verify lint fixes:\nnpm run lint:all');
261 }
262 console.log('---------------------------------');
263 process.exit(1);
264 }
265
266 const completed = allPassed + allFailed;
267 process.stdout.write(
268 `\r⏳ Monitoring ${targetRunIds.length} runs... ${completed}/${totalJobs} jobs (${allPassed} passed, ${allFailed} failed, ${allRunning} running, ${allQueued} queued) `,
269 );
270 if (!anyRunInProgress) {
271 console.log('\n✅ All workflows passed!');
272 process.exit(0);
273 }
274 await new Promise((r) => setTimeout(r, 15000));
275 }
276}
277
278monitor().catch((err) => {
279 console.error('\nMonitor error:', err.message);
280 process.exit(1);
281});