@ibm-cloud/cd-tools 1.7.0 → 1.8.1

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,28 @@ 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
+ }
209
210
  }
210
211
 
211
212
  async function promptUser(name) {
@@ -234,7 +235,7 @@ function validateAndConvertRegion(region) {
234
235
  return `https://${region}.git.cloud.ibm.com/`;
235
236
  }
236
237
 
237
- // Build a mapping of: old http_url_to_repo -> new http_url_to_repo and old web_url -> new web_url
238
+ // Build a mapping of: old http_url_to_repo -> new http_url_to_repo
238
239
  async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
239
240
  const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
240
241
  const urlMapping = {};
@@ -267,6 +268,99 @@ async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinat
267
268
  console.log(`Total mapped projects: ${sourceProjects.length}`);
268
269
  }
269
270
 
271
+ function buildGroupImportHistoryUrl(destUrl) {
272
+ try {
273
+ return new URL('import/bulk_imports/history', destUrl).toString();
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ function summarizeBulkImportProgress(entities = []) {
280
+ let entityTotal = 0;
281
+ let entityFinished = 0;
282
+ let entityFailed = 0;
283
+
284
+ let projectTotal = 0;
285
+ let projectFinished = 0;
286
+ let projectFailed = 0;
287
+
288
+ let lastCompleted = null;
289
+ let lastCompletedTs = 0;
290
+
291
+ for (const e of entities) {
292
+ entityTotal++;
293
+
294
+ const status = e.status;
295
+ const isFinished = status === 'finished';
296
+ const isFailed = status === 'failed';
297
+
298
+ if (isFinished) entityFinished++;
299
+ if (isFailed) entityFailed++;
300
+
301
+ const isProjectEntity =
302
+ e.source_type === 'project_entity' ||
303
+ e.entity_type === 'project_entity' ||
304
+ e.entity_type === 'project';
305
+
306
+ if (isProjectEntity) {
307
+ projectTotal++;
308
+ if (isFinished) projectFinished++;
309
+ if (isFailed) projectFailed++;
310
+ }
311
+
312
+ if (isFinished) {
313
+ const ts = new Date(e.updated_at || e.created_at || 0).getTime();
314
+ if (ts > lastCompletedTs) {
315
+ lastCompletedTs = ts;
316
+ lastCompleted = e;
317
+ }
318
+ }
319
+ }
320
+
321
+ const entityDone = entityFinished + entityFailed;
322
+ const entityPct = entityTotal ? Math.floor((entityDone / entityTotal) * 100) : 0;
323
+
324
+ const projectDone = projectFinished + projectFailed;
325
+ const projectPct = projectTotal ? Math.floor((projectDone / projectTotal) * 100) : 0;
326
+
327
+ const lastCompletedLabel = lastCompleted?.source_full_path || '';
328
+
329
+ return {
330
+ entityTotal,
331
+ entityDone,
332
+ entityFailed,
333
+ entityPct,
334
+ projectTotal,
335
+ projectDone,
336
+ projectFailed,
337
+ projectPct,
338
+ lastCompletedLabel,
339
+ };
340
+ }
341
+
342
+ function formatBulkImportProgressLine(importStatus, summary) {
343
+ if (!summary || summary.entityTotal === 0) {
344
+ return `Import status: ${importStatus} | Progress: initializing...`;
345
+ }
346
+
347
+ const parts = [`Import status: ${importStatus}`];
348
+
349
+ if (summary.projectTotal > 0) {
350
+ parts.push(`Projects: ${summary.projectDone}/${summary.projectTotal} (${summary.projectPct}%)`);
351
+ if (summary.projectFailed > 0) parts.push(`Project failed: ${summary.projectFailed}`);
352
+ }
353
+
354
+ parts.push(`Entities: ${summary.entityDone}/${summary.entityTotal} (${summary.entityPct}%)`);
355
+ if (summary.entityFailed > 0) parts.push(`Failed: ${summary.entityFailed}`);
356
+
357
+ if (summary.lastCompletedLabel) {
358
+ parts.push(`Last completed: ${summary.lastCompletedLabel}`);
359
+ }
360
+
361
+ return parts.join(' | ');
362
+ }
363
+
270
364
  async function directTransfer(options) {
271
365
  const sourceUrl = validateAndConvertRegion(options.sourceRegion);
272
366
  const destUrl = validateAndConvertRegion(options.destRegion);
@@ -333,19 +427,36 @@ async function directTransfer(options) {
333
427
  process.exit(0);
334
428
  }
335
429
 
336
- console.log('\nPolling bulk import status (checking every 5 minute)...');
430
+ console.log('\nPolling bulk import status (adaptive: 1m→2m→3m→4m→5m, max 60 checks)...');
431
+ const MAX_ATTEMPTS = 60;
432
+ const POLLS_PER_STEP = 5;
433
+ const MIN_INTERVAL_MIN = 1;
434
+ const MAX_INTERVAL_MIN = 5;
435
+
337
436
  let importStatus = 'created';
338
437
  let attempts = 0;
339
438
 
340
- while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < 60) {
439
+ while (!['finished', 'failed', 'timeout'].includes(importStatus) && attempts < MAX_ATTEMPTS) {
341
440
  if (attempts > 0) {
342
- console.log(`Waiting 5 minute before next status check...`);
343
- await new Promise(resolve => setTimeout(resolve, 5 * 60000));
441
+ const step = Math.floor(attempts / POLLS_PER_STEP);
442
+ const waitMin = Math.min(MIN_INTERVAL_MIN + step, MAX_INTERVAL_MIN);
443
+
444
+ console.log(`Waiting ${waitMin} minute before next status check...`);
445
+ await new Promise(resolve => setTimeout(resolve, waitMin * 60000));
344
446
  }
345
447
  try {
346
448
  const importDetails = await destination.getBulkImport(bulkImport.id);
347
449
  importStatus = importDetails.status;
348
- console.log(`[${new Date().toLocaleTimeString()}] Import status: ${importStatus}`);
450
+ let progressLine;
451
+ try {
452
+ const entitiesAll = await destination.getBulkImportEntitiesAll(bulkImport.id);
453
+ const summary = summarizeBulkImportProgress(entitiesAll);
454
+ progressLine = formatBulkImportProgressLine(importStatus, summary);
455
+ } catch {
456
+ progressLine = `Import status: ${importStatus} | Progress: (unable to fetch entity details)`;
457
+ }
458
+
459
+ console.log(`[${new Date().toLocaleTimeString()}] ${progressLine}`);
349
460
 
350
461
  if (importStatus === 'finished') {
351
462
  console.log('Bulk import completed successfully!');
@@ -363,8 +474,19 @@ async function directTransfer(options) {
363
474
  attempts++;
364
475
  }
365
476
 
366
- if (attempts >= 60) {
367
- console.error(`Bulk import either timed out or is still running in the background`);
477
+ if (attempts >= MAX_ATTEMPTS) {
478
+ const historyUrl = buildGroupImportHistoryUrl(destUrl);
479
+
480
+ console.error('\nThe CLI has stopped polling for the GitLab bulk import.');
481
+ console.error('The migration itself may still be running inside GitLab — the CLI only waits for a limited time.');
482
+ console.error(`Last reported status for bulk import ${bulkImport.id}: ${importStatus}`);
483
+
484
+ if (historyUrl) {
485
+ console.error('\nYou can continue monitoring this migration in the GitLab UI.');
486
+ console.error(`Group import history: ${historyUrl}`);
487
+ } else {
488
+ console.error('\nYou can continue monitoring this migration from the Group import history page in the GitLab UI.');
489
+ }
368
490
  process.exit(0);
369
491
  }
370
492
 
@@ -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.1",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",