@ibm-cloud/cd-tools 1.7.0 → 1.8.2

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.
@@ -12,6 +12,7 @@ import axios from 'axios';
12
12
  import readline from 'readline/promises';
13
13
  import { writeFile } from 'fs/promises';
14
14
  import { TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
15
+ import { getWithRetry } from './utils/requests.js';
15
16
 
16
17
  const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
17
18
 
@@ -55,7 +56,8 @@ class GitLabClient {
55
56
  return projects;
56
57
  }
57
58
 
58
- const projRes = await this.getWithRetry(
59
+ const projRes = await getWithRetry(
60
+ this.client,
59
61
  `/groups/${currentGroupId}/projects`,
60
62
  { page: projPage, per_page: 100 }
61
63
  );
@@ -82,7 +84,8 @@ class GitLabClient {
82
84
  return projects;
83
85
  }
84
86
 
85
- const subgroupRes = await this.getWithRetry(
87
+ const subgroupRes = await getWithRetry(
88
+ this.client,
86
89
  `/groups/${currentGroupId}/subgroups`,
87
90
  { page: subgroupPage, per_page: 100 }
88
91
  );
@@ -107,30 +110,6 @@ class GitLabClient {
107
110
  return projects;
108
111
  }
109
112
 
110
- // Helper: GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
111
- async getWithRetry(path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
112
- let lastError;
113
- for (let attempt = 1; attempt <= retries; attempt++) {
114
- try {
115
- return await this.client.get(path, { params });
116
- } catch (error) {
117
- const status = error.response?.status;
118
-
119
- if (attempt < retries && status && status >= 500) {
120
- console.warn(
121
- `[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
122
- );
123
- await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
124
- lastError = error;
125
- continue;
126
- }
127
-
128
- throw error; // Non-5xx or out of retries: rethrow
129
- }
130
- }
131
- throw lastError;
132
- }
133
-
134
113
  async getGroup(groupId) {
135
114
  const response = await this.client.get(`/groups/${groupId}`);
136
115
  return response.data;
@@ -206,6 +185,42 @@ class GitLabClient {
206
185
  throw new Error(`Bulk import API call failed: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`);
207
186
  }
208
187
  }
188
+
189
+ async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
190
+ const all = [];
191
+ let page = 1;
192
+
193
+ while (page <= maxPages) {
194
+ const resp = await getWithRetry(
195
+ this.client,
196
+ `/bulk_imports/${importId}/entities`,
197
+ { page, per_page: perPage }
198
+ );
199
+
200
+ all.push(...(resp.data || []));
201
+
202
+ const nextPage = Number(resp.headers?.['x-next-page'] || 0);
203
+ if (!nextPage) break;
204
+
205
+ page = nextPage;
206
+ }
207
+
208
+ return all;
209
+ }
210
+
211
+ async getGroupByFullPath(fullPath) {
212
+ const encoded = encodeURIComponent(fullPath);
213
+ const resp = await this.client.get(`/groups/${encoded}`);
214
+ return resp.data;
215
+ }
216
+
217
+ async listBulkImports({ page = 1, perPage = 50 } = {}) {
218
+ const resp = await getWithRetry(this.client, `/bulk_imports`, { page, per_page: perPage });
219
+ return {
220
+ imports: resp.data || [],
221
+ nextPage: Number(resp.headers?.['x-next-page'] || 0),
222
+ };
223
+ }
209
224
  }
210
225
 
211
226
  async function promptUser(name) {
@@ -234,7 +249,7 @@ function validateAndConvertRegion(region) {
234
249
  return `https://${region}.git.cloud.ibm.com/`;
235
250
  }
236
251
 
237
- // Build a mapping of: old http_url_to_repo -> new http_url_to_repo and old web_url -> new web_url
252
+ // Build a mapping of: old http_url_to_repo -> new http_url_to_repo
238
253
  async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
239
254
  const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
240
255
  const urlMapping = {};
@@ -267,6 +282,177 @@ async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinat
267
282
  console.log(`Total mapped projects: ${sourceProjects.length}`);
268
283
  }
269
284
 
285
+ function buildGroupImportHistoryUrl(destUrl) {
286
+ try {
287
+ return new URL('import/bulk_imports/history', destUrl).toString();
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ function summarizeBulkImportProgress(entities = []) {
294
+ let entityTotal = 0;
295
+ let entityFinished = 0;
296
+ let entityFailed = 0;
297
+
298
+ let projectTotal = 0;
299
+ let projectFinished = 0;
300
+ let projectFailed = 0;
301
+
302
+ let lastCompleted = null;
303
+ let lastCompletedTs = 0;
304
+
305
+ for (const e of entities) {
306
+ entityTotal++;
307
+
308
+ const status = e.status;
309
+ const isFinished = status === 'finished';
310
+ const isFailed = status === 'failed';
311
+
312
+ if (isFinished) entityFinished++;
313
+ if (isFailed) entityFailed++;
314
+
315
+ const isProjectEntity =
316
+ e.source_type === 'project_entity' ||
317
+ e.entity_type === 'project_entity' ||
318
+ e.entity_type === 'project';
319
+
320
+ if (isProjectEntity) {
321
+ projectTotal++;
322
+ if (isFinished) projectFinished++;
323
+ if (isFailed) projectFailed++;
324
+ }
325
+
326
+ if (isFinished) {
327
+ const ts = new Date(e.updated_at || e.created_at || 0).getTime();
328
+ if (ts > lastCompletedTs) {
329
+ lastCompletedTs = ts;
330
+ lastCompleted = e;
331
+ }
332
+ }
333
+ }
334
+
335
+ const entityDone = entityFinished + entityFailed;
336
+ const entityPct = entityTotal ? Math.floor((entityDone / entityTotal) * 100) : 0;
337
+
338
+ const projectDone = projectFinished + projectFailed;
339
+ const projectPct = projectTotal ? Math.floor((projectDone / projectTotal) * 100) : 0;
340
+
341
+ const lastCompletedLabel = lastCompleted?.source_full_path || '';
342
+
343
+ return {
344
+ entityTotal,
345
+ entityDone,
346
+ entityFailed,
347
+ entityPct,
348
+ projectTotal,
349
+ projectDone,
350
+ projectFailed,
351
+ projectPct,
352
+ lastCompletedLabel,
353
+ };
354
+ }
355
+
356
+ function formatBulkImportProgressLine(importStatus, summary) {
357
+ if (!summary || summary.entityTotal === 0) {
358
+ return `Import status: ${importStatus} | Progress: initializing...`;
359
+ }
360
+
361
+ const parts = [`Import status: ${importStatus}`];
362
+
363
+ if (summary.projectTotal > 0) {
364
+ parts.push(`Projects: ${summary.projectDone}/${summary.projectTotal} (${summary.projectPct}%)`);
365
+ if (summary.projectFailed > 0) parts.push(`Project failed: ${summary.projectFailed}`);
366
+ }
367
+
368
+ parts.push(`Entities: ${summary.entityDone}/${summary.entityTotal} (${summary.entityPct}%)`);
369
+ if (summary.entityFailed > 0) parts.push(`Failed: ${summary.entityFailed}`);
370
+
371
+ if (summary.lastCompletedLabel) {
372
+ parts.push(`Last completed: ${summary.lastCompletedLabel}`);
373
+ }
374
+
375
+ return parts.join(' | ');
376
+ }
377
+
378
+ function buildGroupUrl(base, path) {
379
+ try {
380
+ return new URL(path.replace(/^\//, ''), base).toString();
381
+ } catch {
382
+ return null;
383
+ }
384
+ }
385
+
386
+ function isGroupEntity(e) {
387
+ return e?.source_type === 'group_entity' || e?.entity_type === 'group_entity' || e?.entity_type === 'group';
388
+ }
389
+
390
+ async function handleBulkImportConflict({destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr}) {
391
+ const historyUrl = buildGroupImportHistoryUrl(destUrl);
392
+ const groupUrl = buildGroupUrl(destUrl, `/groups/${destinationGroupPath}`);
393
+ const fallback = () => {
394
+ console.log(`\nDestination group already exists.`);
395
+ if (groupUrl) console.log(`Group: ${groupUrl}`);
396
+ if (historyUrl) console.log(`Group import history: ${historyUrl}`);
397
+ process.exit(0);
398
+ };
399
+
400
+ try {
401
+ await destination.getGroupByFullPath(destinationGroupPath);
402
+ } catch {
403
+ fallback();
404
+ }
405
+
406
+ try {
407
+ const IMPORT_PAGES = 3;
408
+ const ENTITY_PAGES = 2;
409
+
410
+ let page = 1;
411
+ for (let p = 0; p < IMPORT_PAGES; p++) {
412
+ const { imports, nextPage } = await destination.listBulkImports({ page, perPage: 50 });
413
+
414
+ for (const bi of imports) {
415
+ if (!bi?.id) continue;
416
+
417
+ const status = bi.status;
418
+ if (!['created', 'started', 'finished'].includes(status)) continue;
419
+
420
+ const entities = await destination.getBulkImportEntitiesAll(bi.id, { perPage: 100, maxPages: ENTITY_PAGES });
421
+
422
+ const matchesThisGroup = entities.some(e =>
423
+ isGroupEntity(e) &&
424
+ e.source_full_path === sourceGroupFullPath &&
425
+ (e.destination_full_path === destinationGroupPath || e.destination_slug === destinationGroupPath)
426
+ );
427
+
428
+ if (!matchesThisGroup) continue;
429
+
430
+ if (status === 'created' || status === 'started') {
431
+ console.log(`\nGroup is already in migration...`);
432
+ console.log(`Bulk import ID: ${bi.id}`);
433
+ if (groupUrl) console.log(`Migrated group: ${groupUrl}`);
434
+ if (historyUrl) console.log(`Group import history: ${historyUrl}`);
435
+ process.exit(0);
436
+ }
437
+
438
+ console.log(`\nConflict detected: ${importResErr}`);
439
+ console.log(`Please specify a new group name using -n, --new-name <n> when trying again`);
440
+ console.log(`\nGroup already migrated.`);
441
+ if (groupUrl) console.log(`Migrated group: ${groupUrl}`);
442
+ if (historyUrl) console.log(`Group import history: ${historyUrl}`);
443
+ process.exit(0);
444
+ }
445
+
446
+ if (!nextPage) break;
447
+ page = nextPage;
448
+ }
449
+
450
+ fallback();
451
+ } catch {
452
+ fallback();
453
+ }
454
+ }
455
+
270
456
  async function directTransfer(options) {
271
457
  const sourceUrl = validateAndConvertRegion(options.sourceRegion);
272
458
  const destUrl = validateAndConvertRegion(options.destRegion);
@@ -324,28 +510,49 @@ async function directTransfer(options) {
324
510
  console.log(`Bulk import request succeeded!`);
325
511
  console.log(`Bulk import initiated successfully (ID: ${importRes.data?.id})`);
326
512
  } else if (importRes.conflict) {
327
- console.log(`Conflict detected: ${importRes.error}`);
328
- console.log(`Please specify a new group name using -n, --new-name <n> when trying again`);
329
- process.exit(0);
513
+ await handleBulkImportConflict({
514
+ destination,
515
+ destUrl,
516
+ sourceGroupFullPath: sourceGroup.full_path,
517
+ destinationGroupPath,
518
+ importResErr: importRes.error
519
+ });
330
520
  }
331
521
  } catch (error) {
332
522
  console.log(`Bulk import request failed - ${error.message}`);
333
523
  process.exit(0);
334
524
  }
335
525
 
336
- console.log('\nPolling bulk import status (checking every 5 minute)...');
526
+ console.log('\nPolling bulk import status (adaptive: 1m→2m→3m→4m→5m, max 60 checks)...');
527
+ const MAX_ATTEMPTS = 60;
528
+ const POLLS_PER_STEP = 5;
529
+ const MIN_INTERVAL_MIN = 1;
530
+ const MAX_INTERVAL_MIN = 5;
531
+
337
532
  let importStatus = 'created';
338
533
  let attempts = 0;
339
534
 
340
- while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < 60) {
535
+ while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < MAX_ATTEMPTS) {
341
536
  if (attempts > 0) {
342
- console.log(`Waiting 5 minute before next status check...`);
343
- await new Promise(resolve => setTimeout(resolve, 5 * 60000));
537
+ const step = Math.floor(attempts / POLLS_PER_STEP);
538
+ const waitMin = Math.min(MIN_INTERVAL_MIN + step, MAX_INTERVAL_MIN);
539
+
540
+ console.log(`Waiting ${waitMin} minute before next status check...`);
541
+ await new Promise(resolve => setTimeout(resolve, waitMin * 60000));
344
542
  }
345
543
  try {
346
544
  const importDetails = await destination.getBulkImport(bulkImport.id);
347
545
  importStatus = importDetails.status;
348
- console.log(`[${new Date().toLocaleTimeString()}] Import status: ${importStatus}`);
546
+ let progressLine;
547
+ try {
548
+ const entitiesAll = await destination.getBulkImportEntitiesAll(bulkImport.id);
549
+ const summary = summarizeBulkImportProgress(entitiesAll);
550
+ progressLine = formatBulkImportProgressLine(importStatus, summary);
551
+ } catch {
552
+ progressLine = `Import status: ${importStatus} | Progress: (unable to fetch entity details)`;
553
+ }
554
+
555
+ console.log(`[${new Date().toLocaleTimeString()}] ${progressLine}`);
349
556
 
350
557
  if (importStatus === 'finished') {
351
558
  console.log('Bulk import completed successfully!');
@@ -363,8 +570,19 @@ async function directTransfer(options) {
363
570
  attempts++;
364
571
  }
365
572
 
366
- if (attempts >= 60) {
367
- console.error(`Bulk import either timed out or is still running in the background`);
573
+ if (attempts >= MAX_ATTEMPTS) {
574
+ const historyUrl = buildGroupImportHistoryUrl(destUrl);
575
+
576
+ console.error('\nThe CLI has stopped polling for the GitLab bulk import.');
577
+ console.error('The migration itself may still be running inside GitLab — the CLI only waits for a limited time.');
578
+ console.error(`Last reported status for bulk import ${bulkImport.id}: ${importStatus}`);
579
+
580
+ if (historyUrl) {
581
+ console.error('\nYou can continue monitoring this migration in the GitLab UI.');
582
+ console.error(`Group import history: ${historyUrl}`);
583
+ } else {
584
+ console.error('\nYou can continue monitoring this migration from the Group import history page in the GitLab UI.');
585
+ }
368
586
  process.exit(0);
369
587
  }
370
588
 
@@ -384,6 +602,8 @@ async function directTransfer(options) {
384
602
  console.log(`${e.source_type}: ${e.source_full_path} (${e.status})`);
385
603
  });
386
604
  }
605
+ const migratedGroupUrl = buildGroupUrl(destUrl, `/groups/${destinationGroupPath}`);
606
+ if (migratedGroupUrl) console.log(`\nMigrated group: ${migratedGroupUrl}`);
387
607
 
388
608
  return 0;
389
609
  } else {
@@ -477,6 +477,28 @@ async function migrateToolchainSecrets(bearer, data, region) {
477
477
  }
478
478
  }
479
479
 
480
+ // GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
481
+ async function getWithRetry(client, path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
482
+ let lastError;
483
+ for (let attempt = 1; attempt <= retries; attempt++) {
484
+ try {
485
+ return await client.get(path, { params });
486
+ } catch (error) {
487
+ const status = error.response?.status;
488
+ if (attempt < retries && status && status >= 500) {
489
+ console.warn(
490
+ `[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
491
+ );
492
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
493
+ lastError = error;
494
+ continue;
495
+ }
496
+ throw error; // Non-5xx or out of retries: rethrow
497
+ }
498
+ }
499
+ throw lastError;
500
+ }
501
+
480
502
  export {
481
503
  getBearerToken,
482
504
  getAccountId,
@@ -495,5 +517,6 @@ export {
495
517
  deleteToolchain,
496
518
  createTool,
497
519
  getSmInstances,
498
- migrateToolchainSecrets
520
+ migrateToolchainSecrets,
521
+ getWithRetry
499
522
  }
@@ -205,7 +205,7 @@ async function setupTerraformFiles({ token, srcRegion, targetRegion, targetTag,
205
205
  logger.print('Please enter the new URLs for the following GRIT tool(s) (or submit empty input to skip):\n');
206
206
  }
207
207
 
208
- const newRepoSlug = await promptUserInput(`Old URL: ${thisUrl.slice(0, thisUrl.length - 4)}\nNew URL: ${GIT_BASE_URL || 'https://' + targetRegion + '.git.cloud.ibm.com'}`, '', validateGritUrlPrompt);
208
+ const newRepoSlug = await promptUserInput(`Old URL: ${thisUrl.slice(0, thisUrl.length - 4)}\nNew URL: ${GIT_BASE_URL || 'https://' + targetRegion + '.git.cloud.ibm.com'}/`, '', validateGritUrlPrompt);
209
209
 
210
210
  if (newRepoSlug) {
211
211
  newUrl = (GIT_BASE_URL || `https://${targetRegion}.git.cloud.ibm.com`) + `/${newRepoSlug}.git`;
@@ -405,14 +405,17 @@ async function validateOAuth(token, tools, targetRegion, skipPrompt) {
405
405
 
406
406
  async function validateGritUrl(token, region, url, validateFull) {
407
407
  if (typeof url != 'string') throw Error('Provided GRIT url is not a string');
408
- let trimmed;
408
+ let trimmed = url.trim();
409
409
 
410
410
  if (validateFull) {
411
411
  const baseUrl = (GIT_BASE_URL || `https://${region}.git.cloud.ibm.com`) + '/';
412
- if (!url.startsWith(baseUrl) || !url.endsWith('.git')) throw Error('Provided full GRIT url is not valid');
413
- trimmed = url.slice(baseUrl.length, url.length - '.git'.length);
414
- } else {
415
- trimmed = url.trim();
412
+ if (!trimmed.startsWith(baseUrl)) throw Error('Provided full GRIT url is not valid');
413
+
414
+ if (trimmed.endsWith('.git')) {
415
+ trimmed = trimmed.slice(baseUrl.length, trimmed.length - '.git'.length);
416
+ } else {
417
+ trimmed = trimmed.slice(baseUrl.length);
418
+ }
416
419
  }
417
420
 
418
421
  // split into two parts, user/group/subgroup and project
@@ -468,7 +471,7 @@ async function validateGritUrl(token, region, url, validateFull) {
468
471
  await getGritGroupProject(accessToken, region, urlStart, projectName);
469
472
  return trimmed;
470
473
  } catch {
471
- throw Error('Provided GRIT url not found');
474
+ throw Error(`Provided GRIT url not found: ${url}`);
472
475
  }
473
476
  }
474
477
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibm-cloud/cd-tools",
3
- "version": "1.7.0",
3
+ "version": "1.8.2",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",