Skip to content

feat: Add hs app migrate command #1406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
193c571
wip
joe-yeager Mar 12, 2025
307a833
wip
joe-yeager Mar 18, 2025
2d96fbb
Fake it
joe-yeager Mar 18, 2025
503b2c8
wip
joe-yeager Mar 19, 2025
99f343e
Refactor
joe-yeager Mar 21, 2025
b9e1611
Add additional reasons
joe-yeager Mar 21, 2025
175e36c
Review copy with Jono
joe-yeager Mar 26, 2025
e7350b4
test
joe-yeager Mar 27, 2025
0d20207
Integration tweaks
joe-yeager Mar 28, 2025
143ad49
v7.2.3-experimental.0
joe-yeager Mar 28, 2025
ac26a4b
Add logs displaying what will be migrated, prompt to proceed
joe-yeager Mar 31, 2025
009fe74
Remove duplicate tracking event, add shell for hs project migrate
joe-yeager Apr 1, 2025
84013dd
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 3, 2025
3f556b3
Clean up
joe-yeager Apr 3, 2025
78cb580
Clean up, add tests
joe-yeager Apr 3, 2025
79ee7c8
Implement polling
joe-yeager Apr 3, 2025
54ca912
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 7, 2025
80e3001
Udpate after LDL release
joe-yeager Apr 7, 2025
87fd9d5
Add defaultAnswer for dest
joe-yeager Apr 7, 2025
39393cf
clean up
joe-yeager Apr 7, 2025
505df3d
Update LDL version
joe-yeager Apr 7, 2025
bbbed83
Fix wierd flag behavior
joe-yeager Apr 7, 2025
4907c78
Remove async and promise wrapper
joe-yeager Apr 7, 2025
8bb55a6
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 7, 2025
bfafd18
Feedback
joe-yeager Apr 7, 2025
9ce5273
feedback
joe-yeager Apr 7, 2025
adfcc01
Get it working with new endpoints
joe-yeager Apr 8, 2025
91b557e
Use experimental release
joe-yeager Apr 8, 2025
cd6fe01
Remove console.logs
joe-yeager Apr 8, 2025
1e5b7c0
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 8, 2025
2fcbaa9
Fixes
joe-yeager Apr 8, 2025
d1b3243
Undo
joe-yeager Apr 8, 2025
110baf1
Inline i18nkey
joe-yeager Apr 8, 2025
210cb5f
clean up
joe-yeager Apr 9, 2025
223f554
PR feedback, move stuff around, add API calls to CLI
joe-yeager Apr 9, 2025
6daa0df
Fix bad import
joe-yeager Apr 9, 2025
8ac0831
Remove migration spinner
joe-yeager Apr 9, 2025
6bff187
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 9, 2025
9d60f24
Feedback from demo
joe-yeager Apr 9, 2025
04e54f6
fix bug with filtering apps
joe-yeager Apr 9, 2025
e2d634c
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/migrati…
joe-yeager Apr 10, 2025
9c02037
Hide zip extraction logs
joe-yeager Apr 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions api/__tests__/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { http } from '@hubspot/local-dev-lib/http';
import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration';
import {
listAppsForMigration,
initializeMigration,
continueMigration,
checkMigrationStatusV2,
ListAppsResponse,
MigrationStatus,
} from '../migrate';

jest.mock('@hubspot/local-dev-lib/http');

const httpMock = http as jest.Mocked<typeof http>;

describe('api/migrate', () => {
const mockAccountId = 12345;
const mockPortalId = 12345;
const mockAppId = 67890;
const mockMigrationId = 54321;
const mockPlatformVersion = '2025.2';
const mockProjectName = 'Test Project';
const mockComponentUids = { 'component-1': 'uid-1', 'component-2': 'uid-2' };

describe('listAppsForMigration', () => {
it('should call http.get with correct parameters', async () => {
const mockResponse: ListAppsResponse = {
migratableApps: [
{
appId: 1,
appName: 'App 1',
isMigratable: true,
migrationComponents: [
{ id: 'comp1', componentType: 'type1', isSupported: true },
],
},
],
unmigratableApps: [
{
appId: 2,
appName: 'App 2',
isMigratable: false,
unmigratableReason: 'UP_TO_DATE',
migrationComponents: [
{ id: 'comp2', componentType: 'type2', isSupported: false },
],
},
],
};

// @ts-expect-error Mock
httpMock.get.mockResolvedValue(mockResponse);

const result = await listAppsForMigration(mockAccountId);

expect(http.get).toHaveBeenCalledWith(mockAccountId, {
url: 'dfs/migrations/v2/list-apps',
});
expect(result).toEqual(mockResponse);
});
});

describe('initializeMigration', () => {
it('should call http.post with correct parameters', async () => {
const mockResponse = { migrationId: mockMigrationId };
// @ts-expect-error Mock
httpMock.post.mockResolvedValue(mockResponse);

const result = await initializeMigration(
mockAccountId,
mockAppId,
mockPlatformVersion
);

expect(http.post).toHaveBeenCalledWith(mockAccountId, {
url: 'dfs/migrations/v2/migrations',
data: {
applicationId: mockAppId,
platformVersion: 'V2025_2',
},
});
expect(result).toEqual(mockResponse);
});
});

describe('continueMigration', () => {
it('should call http.post with correct parameters', async () => {
const mockResponse = { migrationId: mockMigrationId };
// @ts-expect-error Mock
httpMock.post.mockResolvedValue(mockResponse);

const result = await continueMigration(
mockPortalId,
mockMigrationId,
mockComponentUids,
mockProjectName
);

expect(http.post).toHaveBeenCalledWith(mockPortalId, {
url: 'dfs/migrations/v2/migrations/continue',
data: {
migrationId: mockMigrationId,
projectName: mockProjectName,
componentUids: mockComponentUids,
},
});
expect(result).toEqual(mockResponse);
});
});

describe('checkMigrationStatusV2', () => {
it('should call http.get with correct parameters for in-progress status', async () => {
const mockResponse: MigrationStatus = {
id: mockMigrationId,
status: MIGRATION_STATUS.IN_PROGRESS,
};
// @ts-expect-error Mock
httpMock.get.mockResolvedValue(mockResponse);

const result = await checkMigrationStatusV2(
mockAccountId,
mockMigrationId
);

expect(http.get).toHaveBeenCalledWith(mockAccountId, {
url: `dfs/migrations/v2/migrations/${mockMigrationId}/status`,
});
expect(result).toEqual(mockResponse);
});

it('should handle input required status', async () => {
const mockResponse: MigrationStatus = {
id: mockMigrationId,
status: MIGRATION_STATUS.INPUT_REQUIRED,
componentsRequiringUids: {
'component-1': {
componentType: 'type1',
componentHint: 'hint1',
},
},
};
// @ts-expect-error Mock
httpMock.get.mockResolvedValue(mockResponse);

const result = await checkMigrationStatusV2(
mockAccountId,
mockMigrationId
);

expect(result).toEqual(mockResponse);
});

it('should handle success status', async () => {
const mockResponse: MigrationStatus = {
id: mockMigrationId,
status: MIGRATION_STATUS.SUCCESS,
buildId: 98765,
};
// @ts-expect-error Mock
httpMock.get.mockResolvedValue(mockResponse);

const result = await checkMigrationStatusV2(
mockAccountId,
mockMigrationId
);

expect(result).toEqual(mockResponse);
});

it('should handle failure status', async () => {
const mockResponse: MigrationStatus = {
id: mockMigrationId,
status: MIGRATION_STATUS.FAILURE,
projectErrorsDetail: 'Error details',
componentErrorDetails: {
'component-1': 'Component error',
},
};
// @ts-expect-error Mock
httpMock.get.mockResolvedValue(mockResponse);

const result = await checkMigrationStatusV2(
mockAccountId,
mockMigrationId
);

expect(result).toEqual(mockResponse);
});
});
});
138 changes: 138 additions & 0 deletions api/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http';
import {
PLATFORM_VERSIONS,
UNMIGRATABLE_REASONS,
} from '@hubspot/local-dev-lib/constants/projects';
import { http } from '@hubspot/local-dev-lib/http';
import { MIGRATION_STATUS } from '@hubspot/local-dev-lib/types/Migration';

const MIGRATIONS_API_PATH_V2 = 'dfs/migrations/v2';

interface BaseMigrationApp {
appId: number;
appName: string;
isMigratable: boolean;
migrationComponents: ListAppsMigrationComponent[];
projectName?: string;
}

export interface MigratableApp extends BaseMigrationApp {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these go in the types/ folder?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left them here because we don't really seem to have a hard a fast rule for putting them in the types/ folder. We seem to have types living in files next to the methods that use them as return types, so I went this that approach since they don't really make sense as standalone types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That being said I don't have a strong opinion and if we want to move this to types LMK and I will move them in a follow up, going to merge this as is.

isMigratable: true;
}

export interface UnmigratableApp extends BaseMigrationApp {
isMigratable: false;
unmigratableReason: keyof typeof UNMIGRATABLE_REASONS;
}

export type MigrationApp = MigratableApp | UnmigratableApp;

export interface ListAppsResponse {
migratableApps: MigratableApp[];
unmigratableApps: UnmigratableApp[];
}

export interface InitializeMigrationResponse {
migrationId: number;
}

export interface ListAppsMigrationComponent {
id: string;
componentType: string;
isSupported: boolean;
}

export type ContinueMigrationResponse = {
migrationId: number;
};

export interface MigrationBaseStatus {
id: number;
}

export interface MigrationInProgress extends MigrationBaseStatus {
status: typeof MIGRATION_STATUS.IN_PROGRESS;
}

export interface MigrationInputRequired extends MigrationBaseStatus {
status: typeof MIGRATION_STATUS.INPUT_REQUIRED;
componentsRequiringUids: Record<
string,
{
componentType: string;
componentHint: string;
}
>;
}

export interface MigrationSuccess extends MigrationBaseStatus {
status: typeof MIGRATION_STATUS.SUCCESS;
buildId: number;
}

export interface MigrationFailed extends MigrationBaseStatus {
status: typeof MIGRATION_STATUS.FAILURE;
projectErrorsDetail?: string;
componentErrorDetails: Record<string, string>;
}

export type MigrationStatus =
| MigrationInProgress
| MigrationInputRequired
| MigrationSuccess
| MigrationFailed;

export async function listAppsForMigration(
accountId: number
): HubSpotPromise<ListAppsResponse> {
return http.get<ListAppsResponse>(accountId, {
url: `${MIGRATIONS_API_PATH_V2}/list-apps`,
});
}

function mapPlatformVersionToEnum(platformVersion: string): string {
if (platformVersion === PLATFORM_VERSIONS.unstable) {
return PLATFORM_VERSIONS.unstable.toUpperCase();
}

return `V${platformVersion.replace('.', '_')}`;
}

export async function initializeMigration(
accountId: number,
applicationId: number,
platformVersion: string
): HubSpotPromise<InitializeMigrationResponse> {
return http.post(accountId, {
url: `${MIGRATIONS_API_PATH_V2}/migrations`,
data: {
applicationId,
platformVersion: mapPlatformVersionToEnum(platformVersion),
},
});
}

export async function continueMigration(
portalId: number,
migrationId: number,
componentUids: Record<string, string>,
projectName: string
): HubSpotPromise<ContinueMigrationResponse> {
return http.post(portalId, {
url: `${MIGRATIONS_API_PATH_V2}/migrations/continue`,
data: {
migrationId,
projectName,
componentUids,
},
});
}

export function checkMigrationStatusV2(
accountId: number,
id: number
): HubSpotPromise<MigrationStatus> {
return http.get<MigrationStatus>(accountId, {
url: `${MIGRATIONS_API_PATH_V2}/migrations/${id}/status`,
});
}
2 changes: 2 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const cmsCommand = require('../commands/cms');
const feedbackCommand = require('../commands/feedback');
const doctorCommand = require('../commands/doctor');
const completionCommand = require('../commands/completion');
const appCommand = require('../commands/app');

notifyAboutUpdates();

Expand Down Expand Up @@ -134,6 +135,7 @@ const argv = yargs
.command(feedbackCommand)
.command(doctorCommand)
.command(completionCommand)
.command(appCommand)
.help()
.alias('h', 'help')
.recommendCommands()
Expand Down
5 changes: 3 additions & 2 deletions commands/__tests__/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ jest.mock('../project/download');
jest.mock('../project/open');
jest.mock('../project/dev');
jest.mock('../project/add');
jest.mock('../project/migrateApp');
jest.mock('../project/cloneApp');
jest.mock('../project/migrateApp', () => ({}));
jest.mock('../project/cloneApp', () => ({}));
jest.mock('../project/installDeps');
jest.mock('../../lib/commonOpts');

yargs.command.mockReturnValue(yargs);
yargs.demandCommand.mockReturnValue(yargs);

Expand Down
22 changes: 22 additions & 0 deletions commands/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import migrateCommand from './app/migrate';
import { addGlobalOptions } from '../lib/commonOpts';
import { Argv, CommandModule } from 'yargs';

export const command = ['app', 'apps'];

// Keep the command hidden for now
export const describe = undefined;

export function builder(yargs: Argv) {
addGlobalOptions(yargs);

return yargs.command(migrateCommand).demandCommand(1, '');
}

const appCommand: CommandModule = {
command,
describe,
builder,
handler: () => {},
};
export default appCommand;
Loading