Skip to content

Commit ffe7e32

Browse files
authored
feat: Add hs app migrate command (#1406)
1 parent 2cf07e3 commit ffe7e32

File tree

21 files changed

+1497
-334
lines changed

21 files changed

+1497
-334
lines changed

api/__tests__/migrate.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { http } from '@hubspot/local-dev-lib/http';
2+
import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration';
3+
import {
4+
listAppsForMigration,
5+
initializeMigration,
6+
continueMigration,
7+
checkMigrationStatusV2,
8+
ListAppsResponse,
9+
MigrationStatus,
10+
} from '../migrate';
11+
12+
jest.mock('@hubspot/local-dev-lib/http');
13+
14+
const httpMock = http as jest.Mocked<typeof http>;
15+
16+
describe('api/migrate', () => {
17+
const mockAccountId = 12345;
18+
const mockPortalId = 12345;
19+
const mockAppId = 67890;
20+
const mockMigrationId = 54321;
21+
const mockPlatformVersion = '2025.2';
22+
const mockProjectName = 'Test Project';
23+
const mockComponentUids = { 'component-1': 'uid-1', 'component-2': 'uid-2' };
24+
25+
describe('listAppsForMigration', () => {
26+
it('should call http.get with correct parameters', async () => {
27+
const mockResponse: ListAppsResponse = {
28+
migratableApps: [
29+
{
30+
appId: 1,
31+
appName: 'App 1',
32+
isMigratable: true,
33+
migrationComponents: [
34+
{ id: 'comp1', componentType: 'type1', isSupported: true },
35+
],
36+
},
37+
],
38+
unmigratableApps: [
39+
{
40+
appId: 2,
41+
appName: 'App 2',
42+
isMigratable: false,
43+
unmigratableReason: 'UP_TO_DATE',
44+
migrationComponents: [
45+
{ id: 'comp2', componentType: 'type2', isSupported: false },
46+
],
47+
},
48+
],
49+
};
50+
51+
// @ts-expect-error Mock
52+
httpMock.get.mockResolvedValue(mockResponse);
53+
54+
const result = await listAppsForMigration(mockAccountId);
55+
56+
expect(http.get).toHaveBeenCalledWith(mockAccountId, {
57+
url: 'dfs/migrations/v2/list-apps',
58+
});
59+
expect(result).toEqual(mockResponse);
60+
});
61+
});
62+
63+
describe('initializeMigration', () => {
64+
it('should call http.post with correct parameters', async () => {
65+
const mockResponse = { migrationId: mockMigrationId };
66+
// @ts-expect-error Mock
67+
httpMock.post.mockResolvedValue(mockResponse);
68+
69+
const result = await initializeMigration(
70+
mockAccountId,
71+
mockAppId,
72+
mockPlatformVersion
73+
);
74+
75+
expect(http.post).toHaveBeenCalledWith(mockAccountId, {
76+
url: 'dfs/migrations/v2/migrations',
77+
data: {
78+
applicationId: mockAppId,
79+
platformVersion: 'V2025_2',
80+
},
81+
});
82+
expect(result).toEqual(mockResponse);
83+
});
84+
});
85+
86+
describe('continueMigration', () => {
87+
it('should call http.post with correct parameters', async () => {
88+
const mockResponse = { migrationId: mockMigrationId };
89+
// @ts-expect-error Mock
90+
httpMock.post.mockResolvedValue(mockResponse);
91+
92+
const result = await continueMigration(
93+
mockPortalId,
94+
mockMigrationId,
95+
mockComponentUids,
96+
mockProjectName
97+
);
98+
99+
expect(http.post).toHaveBeenCalledWith(mockPortalId, {
100+
url: 'dfs/migrations/v2/migrations/continue',
101+
data: {
102+
migrationId: mockMigrationId,
103+
projectName: mockProjectName,
104+
componentUids: mockComponentUids,
105+
},
106+
});
107+
expect(result).toEqual(mockResponse);
108+
});
109+
});
110+
111+
describe('checkMigrationStatusV2', () => {
112+
it('should call http.get with correct parameters for in-progress status', async () => {
113+
const mockResponse: MigrationStatus = {
114+
id: mockMigrationId,
115+
status: MIGRATION_STATUS.IN_PROGRESS,
116+
};
117+
// @ts-expect-error Mock
118+
httpMock.get.mockResolvedValue(mockResponse);
119+
120+
const result = await checkMigrationStatusV2(
121+
mockAccountId,
122+
mockMigrationId
123+
);
124+
125+
expect(http.get).toHaveBeenCalledWith(mockAccountId, {
126+
url: `dfs/migrations/v2/migrations/${mockMigrationId}/status`,
127+
});
128+
expect(result).toEqual(mockResponse);
129+
});
130+
131+
it('should handle input required status', async () => {
132+
const mockResponse: MigrationStatus = {
133+
id: mockMigrationId,
134+
status: MIGRATION_STATUS.INPUT_REQUIRED,
135+
componentsRequiringUids: {
136+
'component-1': {
137+
componentType: 'type1',
138+
componentHint: 'hint1',
139+
},
140+
},
141+
};
142+
// @ts-expect-error Mock
143+
httpMock.get.mockResolvedValue(mockResponse);
144+
145+
const result = await checkMigrationStatusV2(
146+
mockAccountId,
147+
mockMigrationId
148+
);
149+
150+
expect(result).toEqual(mockResponse);
151+
});
152+
153+
it('should handle success status', async () => {
154+
const mockResponse: MigrationStatus = {
155+
id: mockMigrationId,
156+
status: MIGRATION_STATUS.SUCCESS,
157+
buildId: 98765,
158+
};
159+
// @ts-expect-error Mock
160+
httpMock.get.mockResolvedValue(mockResponse);
161+
162+
const result = await checkMigrationStatusV2(
163+
mockAccountId,
164+
mockMigrationId
165+
);
166+
167+
expect(result).toEqual(mockResponse);
168+
});
169+
170+
it('should handle failure status', async () => {
171+
const mockResponse: MigrationStatus = {
172+
id: mockMigrationId,
173+
status: MIGRATION_STATUS.FAILURE,
174+
projectErrorsDetail: 'Error details',
175+
componentErrorDetails: {
176+
'component-1': 'Component error',
177+
},
178+
};
179+
// @ts-expect-error Mock
180+
httpMock.get.mockResolvedValue(mockResponse);
181+
182+
const result = await checkMigrationStatusV2(
183+
mockAccountId,
184+
mockMigrationId
185+
);
186+
187+
expect(result).toEqual(mockResponse);
188+
});
189+
});
190+
});

api/migrate.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http';
2+
import {
3+
PLATFORM_VERSIONS,
4+
UNMIGRATABLE_REASONS,
5+
} from '@hubspot/local-dev-lib/constants/projects';
6+
import { http } from '@hubspot/local-dev-lib/http';
7+
import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration';
8+
9+
const MIGRATIONS_API_PATH_V2 = 'dfs/migrations/v2';
10+
11+
interface BaseMigrationApp {
12+
appId: number;
13+
appName: string;
14+
isMigratable: boolean;
15+
migrationComponents: ListAppsMigrationComponent[];
16+
projectName?: string;
17+
}
18+
19+
export interface MigratableApp extends BaseMigrationApp {
20+
isMigratable: true;
21+
}
22+
23+
export interface UnmigratableApp extends BaseMigrationApp {
24+
isMigratable: false;
25+
unmigratableReason: keyof typeof UNMIGRATABLE_REASONS;
26+
}
27+
28+
export type MigrationApp = MigratableApp | UnmigratableApp;
29+
30+
export interface ListAppsResponse {
31+
migratableApps: MigratableApp[];
32+
unmigratableApps: UnmigratableApp[];
33+
}
34+
35+
export interface InitializeMigrationResponse {
36+
migrationId: number;
37+
}
38+
39+
export interface ListAppsMigrationComponent {
40+
id: string;
41+
componentType: string;
42+
isSupported: boolean;
43+
}
44+
45+
export type ContinueMigrationResponse = {
46+
migrationId: number;
47+
};
48+
49+
export interface MigrationBaseStatus {
50+
id: number;
51+
}
52+
53+
export interface MigrationInProgress extends MigrationBaseStatus {
54+
status: typeof MIGRATION_STATUS.IN_PROGRESS;
55+
}
56+
57+
export interface MigrationInputRequired extends MigrationBaseStatus {
58+
status: typeof MIGRATION_STATUS.INPUT_REQUIRED;
59+
componentsRequiringUids: Record<
60+
string,
61+
{
62+
componentType: string;
63+
componentHint: string;
64+
}
65+
>;
66+
}
67+
68+
export interface MigrationSuccess extends MigrationBaseStatus {
69+
status: typeof MIGRATION_STATUS.SUCCESS;
70+
buildId: number;
71+
}
72+
73+
export interface MigrationFailed extends MigrationBaseStatus {
74+
status: typeof MIGRATION_STATUS.FAILURE;
75+
projectErrorsDetail?: string;
76+
componentErrorDetails: Record<string, string>;
77+
}
78+
79+
export type MigrationStatus =
80+
| MigrationInProgress
81+
| MigrationInputRequired
82+
| MigrationSuccess
83+
| MigrationFailed;
84+
85+
export async function listAppsForMigration(
86+
accountId: number
87+
): HubSpotPromise<ListAppsResponse> {
88+
return http.get<ListAppsResponse>(accountId, {
89+
url: `${MIGRATIONS_API_PATH_V2}/list-apps`,
90+
});
91+
}
92+
93+
function mapPlatformVersionToEnum(platformVersion: string): string {
94+
if (platformVersion === PLATFORM_VERSIONS.unstable) {
95+
return PLATFORM_VERSIONS.unstable.toUpperCase();
96+
}
97+
98+
return `V${platformVersion.replace('.', '_')}`;
99+
}
100+
101+
export async function initializeMigration(
102+
accountId: number,
103+
applicationId: number,
104+
platformVersion: string
105+
): HubSpotPromise<InitializeMigrationResponse> {
106+
return http.post(accountId, {
107+
url: `${MIGRATIONS_API_PATH_V2}/migrations`,
108+
data: {
109+
applicationId,
110+
platformVersion: mapPlatformVersionToEnum(platformVersion),
111+
},
112+
});
113+
}
114+
115+
export async function continueMigration(
116+
portalId: number,
117+
migrationId: number,
118+
componentUids: Record<string, string>,
119+
projectName: string
120+
): HubSpotPromise<ContinueMigrationResponse> {
121+
return http.post(portalId, {
122+
url: `${MIGRATIONS_API_PATH_V2}/migrations/continue`,
123+
data: {
124+
migrationId,
125+
projectName,
126+
componentUids,
127+
},
128+
});
129+
}
130+
131+
export function checkMigrationStatusV2(
132+
accountId: number,
133+
id: number
134+
): HubSpotPromise<MigrationStatus> {
135+
return http.get<MigrationStatus>(accountId, {
136+
url: `${MIGRATIONS_API_PATH_V2}/migrations/${id}/status`,
137+
});
138+
}

bin/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const cmsCommand = require('../commands/cms');
5151
const feedbackCommand = require('../commands/feedback');
5252
const doctorCommand = require('../commands/doctor');
5353
const completionCommand = require('../commands/completion');
54+
const appCommand = require('../commands/app');
5455

5556
notifyAboutUpdates();
5657

@@ -134,6 +135,7 @@ const argv = yargs
134135
.command(feedbackCommand)
135136
.command(doctorCommand)
136137
.command(completionCommand)
138+
.command(appCommand)
137139
.help()
138140
.alias('h', 'help')
139141
.recommendCommands()

commands/__tests__/project.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ jest.mock('../project/download');
2525
jest.mock('../project/open');
2626
jest.mock('../project/dev');
2727
jest.mock('../project/add');
28-
jest.mock('../project/migrateApp');
29-
jest.mock('../project/cloneApp');
28+
jest.mock('../project/migrateApp', () => ({}));
29+
jest.mock('../project/cloneApp', () => ({}));
3030
jest.mock('../project/installDeps');
3131
jest.mock('../../lib/commonOpts');
32+
3233
yargs.command.mockReturnValue(yargs);
3334
yargs.demandCommand.mockReturnValue(yargs);
3435

commands/app.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import migrateCommand from './app/migrate';
2+
import { addGlobalOptions } from '../lib/commonOpts';
3+
import { Argv, CommandModule } from 'yargs';
4+
5+
export const command = ['app', 'apps'];
6+
7+
// Keep the command hidden for now
8+
export const describe = undefined;
9+
10+
export function builder(yargs: Argv) {
11+
addGlobalOptions(yargs);
12+
13+
return yargs.command(migrateCommand).demandCommand(1, '');
14+
}
15+
16+
const appCommand: CommandModule = {
17+
command,
18+
describe,
19+
builder,
20+
handler: () => {},
21+
};
22+
export default appCommand;

0 commit comments

Comments
 (0)