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.
@@ -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
+ }