feat(graph): add undo migration option when one is pending approval (#30878)

This PR adds a button for user to undo a migration that's already been
applied and pending approval.

See: https://www.loom.com/share/97286bdc80ea4538af76a914ef8f0f8b

Also, fixes an existing issue where `migrations.json` did not record the
correct git sha for each commit.


## Current Behavior
When a migration is pending approval, the only option is to accept it.

## Expected Behavior
Allow user to undo a migration if they don't want the changes.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Jack Hsu 2025-04-25 16:20:17 -04:00 committed by GitHub
parent 3b0c456bbe
commit 0dc4dbf499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 88 additions and 18 deletions

View File

@ -85,6 +85,13 @@ export function MigrateApp({
});
};
const onUndoMigration = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'undo-migration',
payload: { migration },
});
};
const onViewImplementation = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'view-implementation',
@ -109,6 +116,7 @@ export function MigrateApp({
onFinish={onFinish}
onFileClick={onFileClick}
onSkipMigration={onSkipMigration}
onUndoMigration={onUndoMigration}
onViewImplementation={onViewImplementation}
onViewDocumentation={onViewDocumentation}
></MigrateUI>

View File

@ -21,6 +21,7 @@ export function AutomaticMigration(props: {
nxConsoleMetadata: MigrationsJsonMetadata;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
@ -87,6 +88,7 @@ export function AutomaticMigration(props: {
isInit={isInit}
onRunMigration={props.onRunMigration}
onSkipMigration={props.onSkipMigration}
onUndoMigration={props.onUndoMigration}
onFileClick={props.onFileClick}
onViewImplementation={props.onViewImplementation}
onViewDocumentation={props.onViewDocumentation}

View File

@ -33,6 +33,7 @@ export interface MigrationTimelineProps {
isInit: boolean;
onRunMigration: (migration: MigrationDetailsWithId) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onFileClick: (
migration: MigrationDetailsWithId,
file: Omit<FileChange, 'content'>
@ -53,6 +54,7 @@ export function MigrationTimeline({
currentMigrationHasChanges,
onRunMigration,
onSkipMigration,
onUndoMigration,
onFileClick,
onViewImplementation,
onViewDocumentation,
@ -335,6 +337,18 @@ export function MigrationTimeline({
)}
{currentMigrationHasChanges && (
<>
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
onUndoMigration(currentMigration);
}}
type="button"
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />{' '}
Undo and Skip
</button>
<button
onClick={() => {
toggleMigrationExpanded(currentMigration.id);
@ -346,6 +360,7 @@ export function MigrationTimeline({
<CheckIcon className="h-5 w-5" aria-hidden="true" />{' '}
Approve Changes
</button>
</>
)}
</div>
)}

View File

@ -290,6 +290,9 @@ export const PendingApproval: Story = {
onSkipMigration: (migration) => {
console.log('skip migration', migration);
},
onUndoMigration: (migration) => {
console.log('undo migration', migration);
},
onFileClick: (migration, file) => {
console.log('file click', migration, file);
},

View File

@ -36,6 +36,7 @@ export interface MigrateUIProps {
}
) => void;
onSkipMigration: (migration: MigrationDetailsWithId) => void;
onUndoMigration: (migration: MigrationDetailsWithId) => void;
onCancel: () => void;
onFinish: (squashCommits: boolean) => void;
onFileClick: (
@ -188,13 +189,10 @@ export function MigrateUI(props: MigrateUIProps) {
onRunMigration={(migration) =>
props.onRunMigration(migration, { createCommits })
}
onSkipMigration={(migration) => props.onSkipMigration(migration)}
onViewImplementation={(migration) =>
props.onViewImplementation(migration)
}
onViewDocumentation={(migration) =>
props.onViewDocumentation(migration)
}
onSkipMigration={props.onSkipMigration}
onUndoMigration={props.onUndoMigration}
onViewImplementation={props.onViewImplementation}
onViewDocumentation={props.onViewDocumentation}
onFileClick={props.onFileClick}
/>
</div>

View File

@ -171,6 +171,16 @@ export async function runSingleMigration(
cwd: workspacePath,
encoding: 'utf-8',
});
// The revision changes after the amend, so we need to update it
const amendedGitRef = execSync('git rev-parse HEAD', {
cwd: workspacePath,
encoding: 'utf-8',
}).trim();
modifyMigrationsJsonMetadata(
workspacePath,
updateRefForSuccessfulMigration(migration.id, amendedGitRef)
);
}
} catch (e) {
modifyMigrationsJsonMetadata(
@ -245,6 +255,24 @@ export function addSuccessfulMigration(
};
}
export function updateRefForSuccessfulMigration(id: string, ref: string) {
return (
migrationsJsonMetadata: MigrationsJsonMetadata
): MigrationsJsonMetadata => {
const copied = { ...migrationsJsonMetadata };
if (!copied.completedMigrations) {
copied.completedMigrations = {};
}
const existing = copied.completedMigrations[id];
if (existing && existing.type === 'successful') {
existing.ref = ref;
} else {
throw new Error(`Attempted to update ref for unsuccessful migration`);
}
return copied;
};
}
export function addFailedMigration(id: string, error: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
const copied = { ...migrationsJsonMetadata };
@ -307,3 +335,19 @@ export function readMigrationsJsonMetadata(
const migrationsJson = JSON.parse(readFileSync(migrationsJsonPath, 'utf-8'));
return migrationsJson['nx-console'];
}
export function undoMigration(workspacePath: string, id: string) {
return (migrationsJsonMetadata: MigrationsJsonMetadata) => {
const existing = migrationsJsonMetadata.completedMigrations[id];
if (existing.type !== 'successful')
throw new Error(`undoMigration called on unsuccessful migration: ${id}`);
execSync(`git reset --hard ${existing.ref}^`, {
cwd: workspacePath,
encoding: 'utf-8',
});
migrationsJsonMetadata.completedMigrations[id] = {
type: 'skipped',
};
return migrationsJsonMetadata;
};
}