@ibm-cloud/cd-tools 1.11.0 → 1.11.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.
package/README.md CHANGED
@@ -74,15 +74,20 @@ The `copy-project-group` command copies a [group](https://docs.gitlab.com/user/g
74
74
  $ npx @ibm-cloud/cd-tools copy-project-group -h
75
75
  Usage: @ibm-cloud/cd-tools copy-project-group [options]
76
76
 
77
- Bulk migrate GitLab group projects
77
+ Copies all Git Repos and Issue Tracking projects in a group to another region.
78
+
79
+ Examples:
80
+ npx @ibm-cloud/cd-tools copy-project-group -g "1796019" -s ca-tor -d us-south --st ${PAT_CA_TOR} --dt ${PAT_US_SOUTH}
81
+ Copy all the Git Repos and Issue Tracking projects in the group "mygroup" from the Toronto region to the Dallas, with the same group name.
78
82
 
79
83
  Options:
80
- -s, --source-region <region> Source GitLab instance region
81
- -d, --dest-region <region> Destination GitLab instance region
82
- --st, --source-token <token> Source GitLab access token
83
- --dt, --dest-token <token> Destination GitLab access token
84
- -g, --group-id <id> Source group ID to migrate
85
- -n, --new-name <n> New group path (optional)
84
+ -s, --source-region <region> The source region from which to copy the project group (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")
85
+ -d, --dest-region <region> The destination region to copy the projects to (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")
86
+ --st, --source-token <token> A Git Repos and Issue Tracking personal access token from the source region. The api scope is required on the token.
87
+ --dt, --dest-token <token> A Git Repos and Issue Tracking personal access token from the target region. The api scope is required on the token.
88
+ -g, --group-id <id> The id of the group to copy from the source region (e.g. "1796019"), or the group name (e.g. "mygroup") for top-level groups. For sub-groups, a path
89
+ is also allowed, e.g. "mygroup/subgroup"
90
+ -n, --new-group-slug <slug> (Optional) Destination group URL slug (single path segment, e.g. "mygroup-copy"). Must be unique. Group display name remains the same as source.
86
91
  -h, --help display help for command
87
92
  ```
88
93
 
@@ -153,7 +158,7 @@ Advanced options:
153
158
  -d, --terraform-dir <path> (Optional) The target local directory to store the generated Terraform (.tf) files
154
159
  -D, --dry-run (Optional) Skip running terraform apply; only generate the Terraform (.tf) files
155
160
  -f, --force (Optional) Force the copy toolchain command to run without user confirmation
156
- -S, --skip-s2s (Optional) Skip importing toolchain-generated service-to-service authorizations
161
+ -S, --skip-s2s (Optional) Skip creating toolchain-generated service-to-service authorizations
157
162
  -T, --skip-disable-triggers (Optional) Skip disabling Tekton pipeline Git or timed triggers. Note: This may result in duplicate pipeline runs
158
163
  -C, --compact (Optional) Generate all resources in a single resources.tf file
159
164
  -v, --verbose (Optional) Increase log output
@@ -11,20 +11,24 @@ import { Command } from 'commander';
11
11
  import axios from 'axios';
12
12
  import readline from 'readline/promises';
13
13
  import { writeFile } from 'fs/promises';
14
- import { SOURCE_REGIONS } from '../config.js';
14
+ import { COPY_PROJECT_GROUP_DESC, SOURCE_REGIONS } from '../config.js';
15
15
  import { getWithRetry } from './utils/requests.js';
16
16
 
17
17
  const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
18
18
 
19
19
  class GitLabClient {
20
20
  constructor(baseURL, token) {
21
+ const root = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
22
+
21
23
  this.client = axios.create({
22
- baseURL: baseURL.endsWith('/') ? `${baseURL}api/v4` : `${baseURL}/api/v4`,
24
+ baseURL: `${root}api/v4`,
23
25
  timeout: HTTP_TIMEOUT_MS,
24
- headers: {
25
- 'Authorization': `Bearer ${token}`,
26
- 'Content-Type': 'application/json'
27
- }
26
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
27
+ });
28
+ this.graph = axios.create({
29
+ baseURL: `${root}api`,
30
+ timeout: HTTP_TIMEOUT_MS,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
28
32
  });
29
33
  }
30
34
 
@@ -110,6 +114,50 @@ class GitLabClient {
110
114
  return projects;
111
115
  }
112
116
 
117
+ async listGroupProjectsGraphQL(groupFullPath, { includeSubgroups = true, pageSize = 200, maxProjects = 5000 } = {}) {
118
+ const out = [];
119
+ let after = null;
120
+
121
+ const query = `
122
+ query($fullPath: ID!, $after: String, $includeSubgroups: Boolean!, $pageSize: Int!) {
123
+ group(fullPath: $fullPath) {
124
+ projects(includeSubgroups: $includeSubgroups, first: $pageSize, after: $after) {
125
+ nodes {
126
+ fullPath
127
+ nameWithNamespace
128
+ httpUrlToRepo
129
+ }
130
+ pageInfo {
131
+ hasNextPage
132
+ endCursor
133
+ }
134
+ }
135
+ }
136
+ }
137
+ `;
138
+
139
+ while (out.length < maxProjects) {
140
+ const resp = await this.graph.post("/graphql", {
141
+ query,
142
+ variables: { fullPath: groupFullPath, after, includeSubgroups, pageSize },
143
+ });
144
+
145
+ if (resp.data?.errors?.length) {
146
+ throw new Error(`GraphQL errors: ${JSON.stringify(resp.data.errors)}`);
147
+ }
148
+
149
+ const projects = resp.data?.data?.group?.projects?.nodes || [];
150
+ const pageInfo = resp.data?.data?.group?.projects?.pageInfo;
151
+
152
+ out.push(...projects);
153
+
154
+ if (!pageInfo?.hasNextPage) break;
155
+ after = pageInfo.endCursor;
156
+ }
157
+
158
+ return out;
159
+ }
160
+
113
161
  async getGroup(groupId) {
114
162
  const response = await this.client.get(`/groups/${groupId}`);
115
163
  return response.data;
@@ -250,23 +298,19 @@ function validateAndConvertRegion(region) {
250
298
  }
251
299
 
252
300
  // Build a mapping of: old http_url_to_repo -> new http_url_to_repo
253
- async function generateUrlMappingFile({ _, destUrl, sourceGroup, destinationGroupPath, sourceProjects }) {
301
+ async function generateUrlMappingFile({ destUrl, sourceGroup, destinationGroupPath, sourceProjects }) {
254
302
  const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
255
303
  const urlMapping = {};
256
304
 
257
- const groupPrefix = `${sourceGroup.full_path}/`;
258
-
259
305
  for (const project of sourceProjects) {
260
- const oldRepoUrl = project.http_url_to_repo; // ends with .git
306
+ const oldRepoUrl = project.http_url_to_repo || project.httpUrlToRepo;
261
307
 
262
- // path_with_namespace is like "group/subgroup/project-1"
263
- let relativePath;
264
- if (project.path_with_namespace.startsWith(groupPrefix)) {
265
- relativePath = project.path_with_namespace.slice(groupPrefix.length);
266
- } else {
267
- // Fallback if for some reason full_path is not a prefix
268
- relativePath = project.path_with_namespace;
269
- }
308
+ const fullPath = project.path_with_namespace || project.fullPath || "";
309
+ const groupPrefix = `${sourceGroup.full_path}/`;
310
+
311
+ const relativePath = fullPath.startsWith(groupPrefix)
312
+ ? fullPath.slice(groupPrefix.length)
313
+ : fullPath;
270
314
 
271
315
  const newRepoUrl = `${destBase}/${destinationGroupPath}/${relativePath}.git`;
272
316
  urlMapping[oldRepoUrl] = newRepoUrl;
@@ -436,7 +480,7 @@ async function handleBulkImportConflict({ destination, destUrl, sourceGroupFullP
436
480
  }
437
481
 
438
482
  console.log(`\nConflict detected: ${importResErr}`);
439
- console.log(`Please specify a new group name using -n, --new-name <n> when trying again`);
483
+ console.log(`Please specify a new group name using -n, --new-group-slug <n> when trying again`);
440
484
  console.log(`\nGroup already migrated.`);
441
485
  if (groupUrl) console.log(`Migrated group: ${groupUrl}`);
442
486
  if (historyUrl) console.log(`Group import history: ${historyUrl}`);
@@ -463,23 +507,32 @@ async function directTransfer(options) {
463
507
  console.log(`Fetching source group from ID: ${options.groupId}...`);
464
508
  const sourceGroup = await source.getGroup(options.groupId);
465
509
 
466
- // let destinationGroupName = options.newName || sourceGroup.name;
467
- let destinationGroupPath = options.newName || sourceGroup.path;
510
+ let destinationGroupPath = options.newGroupSlug || sourceGroup.path;
468
511
 
469
- const sourceProjects = await source.getGroupProjects(sourceGroup.id);
512
+ let sourceProjects;
513
+ try {
514
+ sourceProjects = await source.listGroupProjectsGraphQL(sourceGroup.full_path, {
515
+ includeSubgroups: true,
516
+ pageSize: 100,
517
+ maxProjects: 10000,
518
+ });
519
+ } catch (e) {
520
+ console.warn(`[WARN] GraphQL listing failed (${e.message}). Falling back to REST safe listing...`);
521
+ sourceProjects = await source.getGroupProjects(sourceGroup.id);
522
+ }
523
+
470
524
  console.log(`Found ${sourceProjects.length} projects in source group`);
471
525
  if (sourceProjects.length > 0) {
472
526
  console.log('Projects to be migrated:');
473
- sourceProjects.forEach(p => console.log(`${p.name_with_namespace}`));
527
+ sourceProjects.forEach(p => console.log(p.name_with_namespace || p.nameWithNamespace || p.fullPath));
474
528
  }
475
529
 
476
- if (options.newName) {
477
- await promptUser(options.newName);
530
+ if (options.newGroupSlug) {
531
+ await promptUser(options.newGroupSlug);
478
532
  }
479
533
 
480
534
  // Generate URL mapping JSON before starting the migration
481
535
  await generateUrlMappingFile({
482
- sourceUrl,
483
536
  destUrl,
484
537
  sourceGroup,
485
538
  destinationGroupPath,
@@ -624,13 +677,14 @@ async function directTransfer(options) {
624
677
  }
625
678
 
626
679
  const command = new Command('copy-project-group')
627
- .description('Bulk migrate GitLab group projects')
628
- .requiredOption('-s, --source-region <region>', 'Source GitLab instance region')
629
- .requiredOption('-d, --dest-region <region>', 'Destination GitLab instance region')
630
- .requiredOption('--st, --source-token <token>', 'Source GitLab access token')
631
- .requiredOption('--dt, --dest-token <token>', 'Destination GitLab access token')
632
- .requiredOption('-g, --group-id <id>', 'Source group ID to migrate')
633
- .option('-n, --new-name <n>', 'New group path (optional)')
680
+ .summary('Copies all Git Repos and Issue Tracking projects in a group to another region.')
681
+ .description(COPY_PROJECT_GROUP_DESC)
682
+ .requiredOption('-s, --source-region <region>', 'The source region from which to copy the project group (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")')
683
+ .requiredOption('-d, --dest-region <region>', 'The destination region to copy the projects to (choices: "au-syd", "br-sao", "ca-mon", "ca-tor", "eu-de", "eu-es", "eu-gb", "jp-osa", "jp-tok", "us-east", "us-south")')
684
+ .requiredOption('--st, --source-token <token>', 'A Git Repos and Issue Tracking personal access token from the source region. The api scope is required on the token.')
685
+ .requiredOption('--dt, --dest-token <token>', 'A Git Repos and Issue Tracking personal access token from the target region. The api scope is required on the token.')
686
+ .requiredOption('-g, --group-id <id>', 'The id of the group to copy from the source region (e.g. "1796019"), or the group name (e.g. "mygroup") for top-level groups. For sub-groups, a path is also allowed, e.g. "mygroup/subgroup"')
687
+ .option('-n, --new-group-slug <slug>', '(Optional) Destination group URL slug (single path segment, e.g. "mygroup-copy"). Must be unique. Group display name remains the same as source.')
634
688
  .showHelpAfterError()
635
689
  .hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
636
690
  .action(async (options) => {
package/config.js CHANGED
@@ -17,7 +17,13 @@ Examples:
17
17
  Copy a toolchain to the Frankfurt region with the specified name and target resource group, using the given API key
18
18
 
19
19
  Environment Variables:
20
- IBMCLOUD_API_KEY API key used to authenticate. Must have IAM permission to read and create toolchains and service-to-service authorizations in source and target region / resource group`
20
+ IBMCLOUD_API_KEY API key used to authenticate. Must have IAM permission to read and create toolchains and service-to-service authorizations in source and target region / resource group`;
21
+
22
+ const COPY_PROJECT_GROUP_DESC = `Copies all Git Repos and Issue Tracking projects in a group to another region.
23
+
24
+ Examples:
25
+ npx @ibm-cloud/cd-tools copy-project-group -g "1796019" -s ca-tor -d us-south --st \${PAT_CA_TOR} --dt \${PAT_US_SOUTH}
26
+ Copy all the Git Repos and Issue Tracking projects in the group "mygroup" from the Toronto region to the Dallas, with the same group name.`;
21
27
 
22
28
  const DOCS_URL = 'https://github.com/IBM/continuous-delivery-tools';
23
29
 
@@ -222,6 +228,7 @@ const VAULT_REGEX = [
222
228
 
223
229
  export {
224
230
  COPY_TOOLCHAIN_DESC,
231
+ COPY_PROJECT_GROUP_DESC,
225
232
  DOCS_URL,
226
233
  SOURCE_REGIONS,
227
234
  TARGET_REGIONS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibm-cloud/cd-tools",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",