ejar-gitlab-pipeline-automator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ENV_SETUP.md +235 -0
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/package.json +49 -0
- package/pipeline_automation.js +731 -0
- package/postinstall.js +66 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
import { accessSync } from "fs";
|
|
6
|
+
import fs from "fs/promises";
|
|
7
|
+
import { createConnection } from "net";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { chromium, firefox } from "playwright";
|
|
11
|
+
|
|
12
|
+
class GitLabPipelineAutomator {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this.browser = null;
|
|
15
|
+
this.page = null;
|
|
16
|
+
this.pipelineId = null;
|
|
17
|
+
this.chromeProcess = null;
|
|
18
|
+
this.config = {
|
|
19
|
+
scriptsPath: config.scriptsPath || process.env.SCRIPTS_PATH || "/Users/mahadasif/Desktop/wareef-scripts",
|
|
20
|
+
gitlabBaseUrl: config.gitlabBaseUrl || process.env.GITLAB_BASE_URL || "https://devops.nhc.sa",
|
|
21
|
+
gitlabProjectPath: config.gitlabProjectPath || process.env.GITLAB_PROJECT_PATH || "/ejar3/devs/ejar3-run-script-tool",
|
|
22
|
+
cdpPort: config.cdpPort || parseInt(process.env.CDP_PORT) || 9222,
|
|
23
|
+
chromeUserDataDir: config.chromeUserDataDir || process.env.CHROME_USER_DATA_DIR || null,
|
|
24
|
+
chromeProfileDir: config.chromeProfileDir || process.env.CHROME_PROFILE_DIR || "Default",
|
|
25
|
+
...config
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getChromePath() {
|
|
30
|
+
const platform = os.platform();
|
|
31
|
+
switch (platform) {
|
|
32
|
+
case "darwin": // macOS
|
|
33
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
34
|
+
case "win32": // Windows
|
|
35
|
+
return [
|
|
36
|
+
process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe",
|
|
37
|
+
process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe",
|
|
38
|
+
process.env["PROGRAMFILES(X86)"] + "\\Google\\Chrome\\Application\\chrome.exe"
|
|
39
|
+
].find(p => {
|
|
40
|
+
try {
|
|
41
|
+
accessSync(p);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
case "linux": // Linux
|
|
48
|
+
return [
|
|
49
|
+
"/usr/bin/google-chrome",
|
|
50
|
+
"/usr/bin/google-chrome-stable",
|
|
51
|
+
"/usr/bin/chromium",
|
|
52
|
+
"/usr/bin/chromium-browser"
|
|
53
|
+
].find(p => {
|
|
54
|
+
try {
|
|
55
|
+
accessSync(p);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
default:
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async isPortAvailable(port) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const server = createConnection({ port }, () => {
|
|
69
|
+
server.end();
|
|
70
|
+
resolve(false); // Port is in use
|
|
71
|
+
});
|
|
72
|
+
server.on('error', () => {
|
|
73
|
+
resolve(true); // Port is available
|
|
74
|
+
});
|
|
75
|
+
server.setTimeout(1000, () => {
|
|
76
|
+
server.destroy();
|
|
77
|
+
resolve(true); // Timeout means port is likely available
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async checkPortConnection(port) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const client = createConnection({ port }, () => {
|
|
85
|
+
client.end();
|
|
86
|
+
resolve(true); // Port is open and accepting connections
|
|
87
|
+
});
|
|
88
|
+
client.on('error', () => {
|
|
89
|
+
resolve(false); // Port is not accepting connections
|
|
90
|
+
});
|
|
91
|
+
client.setTimeout(1000, () => {
|
|
92
|
+
client.destroy();
|
|
93
|
+
resolve(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async tryConnectToPort(port, retries = 2) {
|
|
99
|
+
for (let i = 0; i < retries; i++) {
|
|
100
|
+
try {
|
|
101
|
+
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, {
|
|
102
|
+
timeout: 3000
|
|
103
|
+
});
|
|
104
|
+
return browser;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (i < retries - 1) {
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getDefaultChromeUserDataDir() {
|
|
115
|
+
const platform = os.platform();
|
|
116
|
+
const homeDir = os.homedir();
|
|
117
|
+
|
|
118
|
+
switch (platform) {
|
|
119
|
+
case "darwin": // macOS
|
|
120
|
+
return path.join(homeDir, "Library", "Application Support", "Google", "Chrome");
|
|
121
|
+
case "win32": // Windows
|
|
122
|
+
return path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "User Data");
|
|
123
|
+
case "linux": // Linux
|
|
124
|
+
return path.join(homeDir, ".config", "google-chrome");
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getAutomationChromeUserDataDir() {
|
|
131
|
+
const platform = os.platform();
|
|
132
|
+
const homeDir = os.homedir();
|
|
133
|
+
const appName = "gitlab-pipeline-automator";
|
|
134
|
+
|
|
135
|
+
switch (platform) {
|
|
136
|
+
case "darwin": // macOS
|
|
137
|
+
return path.join(homeDir, ".config", appName, "chrome-profile");
|
|
138
|
+
case "win32": // Windows
|
|
139
|
+
return path.join(process.env.APPDATA || process.env.LOCALAPPDATA || "", appName, "chrome-profile");
|
|
140
|
+
case "linux": // Linux
|
|
141
|
+
return path.join(homeDir, ".config", appName, "chrome-profile");
|
|
142
|
+
default:
|
|
143
|
+
return path.join(homeDir, ".config", appName, "chrome-profile");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getDefaultChromeProfileLocation() {
|
|
148
|
+
return this.getAutomationChromeUserDataDir();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async ensureChromeUserDataDir() {
|
|
152
|
+
// If user specified a directory, use it
|
|
153
|
+
if (this.config.chromeUserDataDir) {
|
|
154
|
+
try {
|
|
155
|
+
await fs.mkdir(this.config.chromeUserDataDir, { recursive: true });
|
|
156
|
+
console.log(`📁 Using custom Chrome profile: ${this.config.chromeUserDataDir}`);
|
|
157
|
+
return this.config.chromeUserDataDir;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.log(`⚠️ Warning: Could not create/access user data directory: ${err.message}`);
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Otherwise, create and use a default automation profile directory
|
|
165
|
+
const defaultDir = this.getAutomationChromeUserDataDir();
|
|
166
|
+
try {
|
|
167
|
+
await fs.mkdir(defaultDir, { recursive: true });
|
|
168
|
+
console.log(`📁 Chrome profile location: ${defaultDir}`);
|
|
169
|
+
console.log(` (This profile will persist your login credentials)`);
|
|
170
|
+
console.log(` (Location: ${defaultDir})`);
|
|
171
|
+
return defaultDir;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.log(`⚠️ Warning: Could not create automation profile directory: ${err.message}`);
|
|
174
|
+
console.log(` Continuing without persistent profile...`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async launchChromeWithDebugging() {
|
|
180
|
+
const chromePath = this.getChromePath();
|
|
181
|
+
if (!chromePath) {
|
|
182
|
+
throw new Error("Chrome not found. Please install Google Chrome.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log("🚀 Starting Chrome with remote debugging in the background...");
|
|
186
|
+
const args = [
|
|
187
|
+
`--remote-debugging-port=${this.config.cdpPort}`,
|
|
188
|
+
"--no-first-run",
|
|
189
|
+
"--no-default-browser-check",
|
|
190
|
+
"--disable-default-apps"
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
// Ensure user data directory exists (creates default if not specified)
|
|
194
|
+
try {
|
|
195
|
+
const userDataDir = await this.ensureChromeUserDataDir();
|
|
196
|
+
|
|
197
|
+
if (userDataDir) {
|
|
198
|
+
args.push(`--user-data-dir=${userDataDir}`);
|
|
199
|
+
|
|
200
|
+
// Add profile directory if specified (not "Default")
|
|
201
|
+
if (this.config.chromeProfileDir && this.config.chromeProfileDir !== "Default") {
|
|
202
|
+
args.push(`--profile-directory=${this.config.chromeProfileDir}`);
|
|
203
|
+
console.log(`👤 Using profile directory: ${this.config.chromeProfileDir}`);
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
// Fallback: use system default Chrome profile
|
|
207
|
+
const defaultUserDataDir = this.getDefaultChromeUserDataDir();
|
|
208
|
+
if (defaultUserDataDir) {
|
|
209
|
+
try {
|
|
210
|
+
accessSync(defaultUserDataDir);
|
|
211
|
+
// Don't add --user-data-dir, let Chrome use its default
|
|
212
|
+
console.log("📁 Using system default Chrome profile");
|
|
213
|
+
} catch {
|
|
214
|
+
// Default directory doesn't exist, Chrome will create it
|
|
215
|
+
console.log("📁 Chrome will use its default profile location");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.log(`⚠️ Warning: Could not set up Chrome profile: ${err.message}`);
|
|
221
|
+
console.log(" Continuing without persistent profile...");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Launch Chrome in background (detached process)
|
|
225
|
+
// This ensures Chrome runs independently and doesn't block the script
|
|
226
|
+
const platform = os.platform();
|
|
227
|
+
|
|
228
|
+
if (platform === "win32") {
|
|
229
|
+
// Windows: use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS
|
|
230
|
+
this.chromeProcess = spawn(chromePath, args, {
|
|
231
|
+
detached: true,
|
|
232
|
+
stdio: "ignore",
|
|
233
|
+
windowsVerbatimArguments: false,
|
|
234
|
+
shell: false
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
// macOS/Linux: use setsid equivalent
|
|
238
|
+
this.chromeProcess = spawn(chromePath, args, {
|
|
239
|
+
detached: true,
|
|
240
|
+
stdio: "ignore"
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Unref the process so it runs independently
|
|
245
|
+
this.chromeProcess.unref();
|
|
246
|
+
|
|
247
|
+
// Don't wait for the process to exit
|
|
248
|
+
this.chromeProcess.on('error', (err) => {
|
|
249
|
+
console.log(`⚠️ Chrome process error: ${err.message}`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
console.log("⏳ Waiting for Chrome to start...");
|
|
253
|
+
|
|
254
|
+
// Wait for Chrome to be ready (check if port becomes available)
|
|
255
|
+
const maxWaitTime = 10000; // 10 seconds max
|
|
256
|
+
const checkInterval = 500; // Check every 500ms
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
|
|
259
|
+
while (Date.now() - startTime < maxWaitTime) {
|
|
260
|
+
const browser = await this.tryConnectToPort(this.config.cdpPort, 1);
|
|
261
|
+
if (browser) {
|
|
262
|
+
await browser.close();
|
|
263
|
+
console.log("✅ Chrome started successfully with remote debugging");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log("⚠️ Chrome may still be starting. Continuing...");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async connectToExistingChrome() {
|
|
273
|
+
// First, try to connect to existing Chrome
|
|
274
|
+
console.log(`🔍 Checking for existing Chrome on port ${this.config.cdpPort}...`);
|
|
275
|
+
|
|
276
|
+
// Try to connect to the configured port
|
|
277
|
+
this.browser = await this.tryConnectToPort(this.config.cdpPort);
|
|
278
|
+
|
|
279
|
+
if (this.browser) {
|
|
280
|
+
try {
|
|
281
|
+
const contexts = this.browser.contexts();
|
|
282
|
+
|
|
283
|
+
if (contexts.length > 0) {
|
|
284
|
+
this.page = await contexts[0].newPage();
|
|
285
|
+
console.log("✅ Connected to existing Chrome session (new tab opened)");
|
|
286
|
+
return true;
|
|
287
|
+
} else {
|
|
288
|
+
// Browser connected but no contexts, create a new one
|
|
289
|
+
this.page = await this.browser.newPage();
|
|
290
|
+
console.log("✅ Connected to existing Chrome session (new page created)");
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.log(`⚠️ Connected to Chrome but error accessing contexts: ${err.message}`);
|
|
295
|
+
// Try to create a new page anyway
|
|
296
|
+
try {
|
|
297
|
+
this.page = await this.browser.newPage();
|
|
298
|
+
console.log("✅ Connected to existing Chrome session (new page created)");
|
|
299
|
+
return true;
|
|
300
|
+
} catch (pageErr) {
|
|
301
|
+
console.log(`❌ Error creating page: ${pageErr.message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// No connection on configured port, check if port is in use
|
|
307
|
+
const portInUse = await this.checkPortConnection(this.config.cdpPort);
|
|
308
|
+
|
|
309
|
+
if (portInUse) {
|
|
310
|
+
console.log(`⚠️ Port ${this.config.cdpPort} is in use but connection failed.`);
|
|
311
|
+
console.log(` Chrome might be running without remote debugging enabled.`);
|
|
312
|
+
console.log(` To use existing Chrome, restart it with:`);
|
|
313
|
+
console.log(` /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=${this.config.cdpPort}`);
|
|
314
|
+
} else {
|
|
315
|
+
console.log(`⚠️ No Chrome found on port ${this.config.cdpPort}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Try common alternative ports
|
|
319
|
+
const commonPorts = [9223, 9224, 9225, 9226];
|
|
320
|
+
console.log(`🔍 Trying common alternative ports...`);
|
|
321
|
+
|
|
322
|
+
for (const port of commonPorts) {
|
|
323
|
+
const browser = await this.tryConnectToPort(port, 1);
|
|
324
|
+
if (browser) {
|
|
325
|
+
this.browser = browser;
|
|
326
|
+
try {
|
|
327
|
+
const contexts = this.browser.contexts();
|
|
328
|
+
this.page = contexts.length > 0
|
|
329
|
+
? await contexts[0].newPage()
|
|
330
|
+
: await this.browser.newPage();
|
|
331
|
+
console.log(`✅ Connected to existing Chrome on port ${port} (new tab opened)`);
|
|
332
|
+
return true;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.log(`⚠️ Connected to port ${port} but error: ${err.message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// No existing Chrome found, automatically launch a new one in background
|
|
340
|
+
console.log("🚀 No existing Chrome with remote debugging found.");
|
|
341
|
+
console.log(" Automatically starting Chrome in the background...");
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
// Launch Chrome in background - this method handles waiting for it to be ready
|
|
345
|
+
await this.launchChromeWithDebugging();
|
|
346
|
+
|
|
347
|
+
// Connect to the newly launched Chrome
|
|
348
|
+
console.log("🔗 Connecting to Chrome...");
|
|
349
|
+
this.browser = await this.tryConnectToPort(this.config.cdpPort, 5);
|
|
350
|
+
|
|
351
|
+
if (!this.browser) {
|
|
352
|
+
// Give it a bit more time if first attempt fails
|
|
353
|
+
console.log("⏳ Chrome is still starting, waiting a bit more...");
|
|
354
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
355
|
+
this.browser = await this.tryConnectToPort(this.config.cdpPort, 3);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!this.browser) {
|
|
359
|
+
throw new Error("Could not connect to Chrome after launching. Please check if Chrome started successfully.");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const contexts = this.browser.contexts();
|
|
363
|
+
this.page = contexts.length > 0
|
|
364
|
+
? await contexts[0].newPage()
|
|
365
|
+
: await this.browser.newPage();
|
|
366
|
+
|
|
367
|
+
console.log("✅ Connected to Chrome (running in background, new tab opened)");
|
|
368
|
+
return true;
|
|
369
|
+
} catch (launchErr) {
|
|
370
|
+
console.log("❌ Could not launch or connect to Chrome:", launchErr.message);
|
|
371
|
+
console.log("\n💡 Troubleshooting tips:");
|
|
372
|
+
console.log(" 1. Make sure Chrome is installed");
|
|
373
|
+
console.log(" 2. Check if another process is using port " + this.config.cdpPort);
|
|
374
|
+
console.log(" 3. Try closing all Chrome instances and run again");
|
|
375
|
+
console.log(" 4. Or manually start Chrome with:");
|
|
376
|
+
const chromePath = this.getChromePath();
|
|
377
|
+
if (chromePath) {
|
|
378
|
+
console.log(` ${chromePath} --remote-debugging-port=${this.config.cdpPort}`);
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async connectToFirefox() {
|
|
385
|
+
try {
|
|
386
|
+
this.browser = await firefox.launch({ headless: false });
|
|
387
|
+
this.page = await this.browser.newPage();
|
|
388
|
+
console.log("✅ Connected to Firefox browser");
|
|
389
|
+
return true;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.log("❌ Could not connect to Firefox:", err);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async retry(fn, maxAttempts = 3, delayMs = 5000) {
|
|
397
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
398
|
+
try {
|
|
399
|
+
return await fn();
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.log(`⚠️ Attempt ${attempt} failed: ${err.message || err}`);
|
|
402
|
+
if (attempt < maxAttempts) {
|
|
403
|
+
console.log(`🔄 Retrying in ${delayMs / 1000}s...`);
|
|
404
|
+
await this.page.waitForTimeout(delayMs);
|
|
405
|
+
} else {
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async navigateToGitlabPipeline() {
|
|
413
|
+
const targetUrl = `${this.config.gitlabBaseUrl}${this.config.gitlabProjectPath}/-/pipelines/new`;
|
|
414
|
+
await this.page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
|
415
|
+
console.log("✅ GitLab pipeline page opened");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async selectBranch(branchName = "production") {
|
|
419
|
+
console.log(`Selecting branch: ${branchName}`);
|
|
420
|
+
await this.page.reload();
|
|
421
|
+
await this.page.click(".ref-selector #dropdown-toggle-btn-37");
|
|
422
|
+
await this.page.waitForSelector("#base-dropdown-38 ul");
|
|
423
|
+
|
|
424
|
+
const liElements = await this.page.$$("#base-dropdown-38 ul li");
|
|
425
|
+
let index = { development: 5, production: 7, test: 8, uat: 1 }[
|
|
426
|
+
branchName.toLowerCase()
|
|
427
|
+
];
|
|
428
|
+
if (index === undefined) throw new Error(`Invalid branch: ${branchName}`);
|
|
429
|
+
|
|
430
|
+
await liElements[index].click();
|
|
431
|
+
console.log(`✅ Branch '${branchName}' selected`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async readScript(scriptName) {
|
|
435
|
+
const scriptPath = path.join(this.config.scriptsPath, `${scriptName}.rb`);
|
|
436
|
+
return await fs.readFile(scriptPath, "utf8");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fetchTicketDescriptionFromScript(scriptContent) {
|
|
440
|
+
const pattern = /task\s+([a-zA-Z0-9_]+)\s*:\s*:environment\s+do/;
|
|
441
|
+
const match = scriptContent.match(pattern);
|
|
442
|
+
if (match) {
|
|
443
|
+
console.log(`✓ Extracted task name from script: ${match[1]}`);
|
|
444
|
+
return match[1];
|
|
445
|
+
}
|
|
446
|
+
console.log("⚠️ No task name found in script");
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async processCiVariables(ticketDescription, scriptContent, ejarService) {
|
|
451
|
+
if (
|
|
452
|
+
ejarService.toLowerCase().includes("sec") ||
|
|
453
|
+
ejarService.toLowerCase().includes("security-deposit")
|
|
454
|
+
) {
|
|
455
|
+
const extracted = this.fetchTicketDescriptionFromScript(scriptContent);
|
|
456
|
+
if (extracted) {
|
|
457
|
+
console.log(
|
|
458
|
+
`🔄 Replacing ticket description '${ticketDescription}' with extracted '${extracted}'`
|
|
459
|
+
);
|
|
460
|
+
ticketDescription = extracted;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await this.page.waitForSelector(
|
|
465
|
+
'div[data-testid="ci-variable-row-container"]',
|
|
466
|
+
{ timeout: 15000 }
|
|
467
|
+
);
|
|
468
|
+
const containers = await this.page.$$(
|
|
469
|
+
'div[data-testid="ci-variable-row-container"]'
|
|
470
|
+
);
|
|
471
|
+
if (!containers.length) throw new Error("No CI variable containers found");
|
|
472
|
+
|
|
473
|
+
// 1. Ticket description
|
|
474
|
+
const textarea1 = await containers[0].$(
|
|
475
|
+
'[data-testid="pipeline-form-ci-variable-value-field"]'
|
|
476
|
+
);
|
|
477
|
+
await textarea1.fill(ticketDescription);
|
|
478
|
+
|
|
479
|
+
// 2. Service dropdown
|
|
480
|
+
const dropdownBtn = await containers[1].$(
|
|
481
|
+
'[data-testid="pipeline-form-ci-variable-value-dropdown"]'
|
|
482
|
+
);
|
|
483
|
+
await dropdownBtn.click();
|
|
484
|
+
await this.page.waitForSelector("#base-dropdown-54 #listbox-52");
|
|
485
|
+
const options = await this.page.$$("#base-dropdown-54 #listbox-52 li");
|
|
486
|
+
let matched = false;
|
|
487
|
+
for (const opt of options) {
|
|
488
|
+
const text = await opt.getAttribute("data-testid");
|
|
489
|
+
if (text?.toLowerCase().includes(ejarService.toLowerCase())) {
|
|
490
|
+
await opt.click();
|
|
491
|
+
matched = true;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (!matched) await options[4].click();
|
|
496
|
+
|
|
497
|
+
// 3. Script content
|
|
498
|
+
const scriptArea = await containers[2].$(
|
|
499
|
+
'[data-testid="pipeline-form-ci-variable-value-field"]'
|
|
500
|
+
);
|
|
501
|
+
await scriptArea.fill(scriptContent);
|
|
502
|
+
|
|
503
|
+
await this.page.click('[data-testid="run-pipeline-button"]');
|
|
504
|
+
console.log("✅ CI variables set and pipeline started");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async waitForPipelinePage() {
|
|
508
|
+
await this.page.waitForURL(/\/pipelines\/\d+$/, { timeout: 30000 });
|
|
509
|
+
this.pipelineId = this.page.url().split("/").pop();
|
|
510
|
+
console.log(`✅ Pipeline page loaded (ID: ${this.pipelineId})`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async approvePipelineStage() {
|
|
514
|
+
return this.retry(
|
|
515
|
+
async () => {
|
|
516
|
+
console.log("⏳ Waiting for approve stage...");
|
|
517
|
+
const maxAttempts = 20;
|
|
518
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
519
|
+
try {
|
|
520
|
+
const badge = this.page.locator("#ci-badge-approve_prod");
|
|
521
|
+
await badge.waitFor({ timeout: 15000 });
|
|
522
|
+
|
|
523
|
+
const ciIcon = badge.getByTestId("ci-icon");
|
|
524
|
+
const aria = await ciIcon.getAttribute("aria-label");
|
|
525
|
+
|
|
526
|
+
console.log("aria value: ", aria);
|
|
527
|
+
|
|
528
|
+
// const ciIcon = badge.locator('[data-testid="ci-icon"]');
|
|
529
|
+
const iconClass = await ciIcon.getAttribute("class");
|
|
530
|
+
const neutral = iconClass?.includes("ci-icon-variant-neutral");
|
|
531
|
+
const success = iconClass?.includes("ci-icon-variant-success");
|
|
532
|
+
|
|
533
|
+
console.log("iconClass: ", iconClass);
|
|
534
|
+
|
|
535
|
+
if (success) {
|
|
536
|
+
console.log("✅ Approve stage already completed");
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (neutral) {
|
|
541
|
+
console.log("🔘 Approve button available, clicking...");
|
|
542
|
+
const approveButton = badge.locator(
|
|
543
|
+
'[data-testid="ci-action-button"]'
|
|
544
|
+
);
|
|
545
|
+
await approveButton.scrollIntoViewIfNeeded();
|
|
546
|
+
await approveButton.click();
|
|
547
|
+
await this.page.waitForTimeout(2000);
|
|
548
|
+
|
|
549
|
+
const updatedClass = await ciIcon.getAttribute("class");
|
|
550
|
+
console.log("updated class: ", updatedClass);
|
|
551
|
+
if (updatedClass?.includes("ci-icon-variant-success")) {
|
|
552
|
+
console.log("✅ Successfully approved pipeline");
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
console.log("⏳ Approve stage in progress...");
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.log(`⚠️ Error while approving: ${err}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
await this.page.reload();
|
|
563
|
+
console.log(`Retrying... (attempt ${attempt + 1}/${maxAttempts})`);
|
|
564
|
+
await this.page.waitForTimeout(5000);
|
|
565
|
+
}
|
|
566
|
+
throw new Error("❌ Approve stage did not complete in time");
|
|
567
|
+
},
|
|
568
|
+
2,
|
|
569
|
+
10000
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async runPipelineStage() {
|
|
574
|
+
try {
|
|
575
|
+
console.log("Looking for run pipeline badge...");
|
|
576
|
+
const badge = this.page.locator("#ci-badge-runscript_prod");
|
|
577
|
+
await badge.waitFor({ timeout: 10000 });
|
|
578
|
+
console.log("✓ Found run pipeline badge");
|
|
579
|
+
|
|
580
|
+
await badge.click();
|
|
581
|
+
console.log("✓ Redirected to pipeline execution page");
|
|
582
|
+
} catch (err) {
|
|
583
|
+
console.log(`✗ Could not click run pipeline badge: ${err}`);
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
await this.page.waitForTimeout(15000);
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
console.log("Monitor the pipeline execution until completion");
|
|
591
|
+
const maxAttempts = 60;
|
|
592
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
593
|
+
try {
|
|
594
|
+
const ciIcon = this.page.locator(
|
|
595
|
+
'.build-job a[data-testid="ci-icon"]'
|
|
596
|
+
);
|
|
597
|
+
await ciIcon.waitFor({ timeout: 5000 });
|
|
598
|
+
|
|
599
|
+
const ariaLabel = await ciIcon.getAttribute("aria-label");
|
|
600
|
+
if (ariaLabel?.includes("Status: Passed")) {
|
|
601
|
+
console.log(
|
|
602
|
+
`✅ Pipeline execution passed with pipeline_id: ${this.pipelineId}`
|
|
603
|
+
);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
if (ariaLabel?.includes("Status: Failed")) {
|
|
607
|
+
console.log("❌ Pipeline execution failed");
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.log(`⚠️ Could not find pipeline-status-link: ${err}`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.log(`⏳ Checking... (attempt ${attempt + 1}/${maxAttempts})`);
|
|
615
|
+
await this.page.reload();
|
|
616
|
+
await this.page.waitForTimeout(10000);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log("⚠️ Pipeline monitoring timed out");
|
|
620
|
+
return false;
|
|
621
|
+
} catch (err) {
|
|
622
|
+
console.log(`💥 Error monitoring pipeline: ${err}`);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async close() {
|
|
628
|
+
if (this.browser) {
|
|
629
|
+
await this.browser.close();
|
|
630
|
+
console.log("Browser closed");
|
|
631
|
+
}
|
|
632
|
+
// Note: We don't kill the Chrome process as it may be used by the user
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Export the class for use as a module
|
|
637
|
+
export default GitLabPipelineAutomator;
|
|
638
|
+
export { GitLabPipelineAutomator };
|
|
639
|
+
|
|
640
|
+
// Setup commander program
|
|
641
|
+
program
|
|
642
|
+
.name("gitlab-pipeline-automator")
|
|
643
|
+
.description("Automate GitLab pipeline creation, approval, and monitoring")
|
|
644
|
+
.version("1.0.0")
|
|
645
|
+
.requiredOption("-t, --ticket <ticket>", "Ticket description")
|
|
646
|
+
.requiredOption("-s, --script <script>", "Ruby script filename without .rb")
|
|
647
|
+
.requiredOption("-e, --ejar-service <service>", "Ejar3 service name")
|
|
648
|
+
.option("-b, --branch <branch>", "Git branch to use", "production")
|
|
649
|
+
.option("--scripts-path <path>", "Path to scripts directory", process.env.SCRIPTS_PATH)
|
|
650
|
+
.option("--gitlab-url <url>", "GitLab base URL", process.env.GITLAB_BASE_URL)
|
|
651
|
+
.option("--project-path <path>", "GitLab project path", process.env.GITLAB_PROJECT_PATH)
|
|
652
|
+
.option("--cdp-port <port>", "Chrome DevTools Protocol port", "9222")
|
|
653
|
+
.option("--chrome-user-data-dir <path>", "Chrome user data directory (for saved credentials/login)", process.env.CHROME_USER_DATA_DIR)
|
|
654
|
+
.option("--chrome-profile-dir <name>", "Chrome profile directory name (default: 'Default')", process.env.CHROME_PROFILE_DIR || "Default");
|
|
655
|
+
|
|
656
|
+
// Only run CLI if this file is executed directly
|
|
657
|
+
// Check if this is the main module by comparing the resolved paths
|
|
658
|
+
const isMainModule = (() => {
|
|
659
|
+
if (import.meta.url.startsWith('file://')) {
|
|
660
|
+
const currentFile = import.meta.url.replace('file://', '');
|
|
661
|
+
const executedFile = process.argv[1];
|
|
662
|
+
if (!executedFile) return false;
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
const currentPath = path.resolve(currentFile);
|
|
666
|
+
const executedPath = path.resolve(executedFile);
|
|
667
|
+
return currentPath === executedPath ||
|
|
668
|
+
executedPath.includes('pipeline_automation.js') ||
|
|
669
|
+
executedPath.includes('gitlab-pipeline-automator');
|
|
670
|
+
} catch {
|
|
671
|
+
return executedFile.includes('pipeline_automation.js') ||
|
|
672
|
+
executedFile.includes('gitlab-pipeline-automator');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return process.argv[1]?.includes('pipeline_automation.js') ||
|
|
676
|
+
process.argv[1]?.includes('gitlab-pipeline-automator');
|
|
677
|
+
})();
|
|
678
|
+
|
|
679
|
+
if (isMainModule) {
|
|
680
|
+
(async () => {
|
|
681
|
+
// Parse arguments - this will handle --help and --version automatically
|
|
682
|
+
program.parse();
|
|
683
|
+
const args = program.opts();
|
|
684
|
+
const config = {
|
|
685
|
+
// Only set config if CLI args are provided, otherwise let constructor use env vars or defaults
|
|
686
|
+
...(args.scriptsPath && { scriptsPath: args.scriptsPath }),
|
|
687
|
+
...(args.gitlabUrl && { gitlabBaseUrl: args.gitlabUrl }),
|
|
688
|
+
...(args.projectPath && { gitlabProjectPath: args.projectPath }),
|
|
689
|
+
...(args.cdpPort && { cdpPort: parseInt(args.cdpPort) }),
|
|
690
|
+
...(args.chromeUserDataDir && { chromeUserDataDir: args.chromeUserDataDir }),
|
|
691
|
+
...(args.chromeProfileDir && { chromeProfileDir: args.chromeProfileDir })
|
|
692
|
+
};
|
|
693
|
+
const automator = new GitLabPipelineAutomator(config);
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
if (!(await automator.connectToExistingChrome())) {
|
|
697
|
+
await automator.connectToFirefox();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Wrap the whole core flow in retry
|
|
701
|
+
await automator.retry(
|
|
702
|
+
async () => {
|
|
703
|
+
await automator.navigateToGitlabPipeline();
|
|
704
|
+
await automator.selectBranch(args.branch);
|
|
705
|
+
const script = await automator.readScript(args.script);
|
|
706
|
+
await automator.processCiVariables(
|
|
707
|
+
args.ticket,
|
|
708
|
+
script,
|
|
709
|
+
args.ejarService
|
|
710
|
+
);
|
|
711
|
+
await automator.waitForPipelinePage();
|
|
712
|
+
},
|
|
713
|
+
3,
|
|
714
|
+
1000
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
console.log("✅ Pipeline page loaded");
|
|
718
|
+
console.log("⏳ Waiting for approve stage...");
|
|
719
|
+
await automator.approvePipelineStage();
|
|
720
|
+
|
|
721
|
+
console.log("🎉 Pipeline approved successfully");
|
|
722
|
+
|
|
723
|
+
await automator.runPipelineStage();
|
|
724
|
+
} catch (err) {
|
|
725
|
+
console.error("💥 Automation failed:", err);
|
|
726
|
+
process.exit(1);
|
|
727
|
+
} finally {
|
|
728
|
+
await automator.close();
|
|
729
|
+
}
|
|
730
|
+
})();
|
|
731
|
+
}
|