diff --git a/workspaces/x2a/.changeset/old-lamps-beg.md b/workspaces/x2a/.changeset/old-lamps-beg.md new file mode 100644 index 0000000000..33d05a87c4 --- /dev/null +++ b/workspaces/x2a/.changeset/old-lamps-beg.md @@ -0,0 +1,9 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-mcp-extras': minor +'@red-hat-developer-hub/backstage-plugin-x2a-backend': minor +'@red-hat-developer-hub/backstage-plugin-x2a-common': minor +'@red-hat-developer-hub/backstage-plugin-x2a-node': minor +'@red-hat-developer-hub/backstage-plugin-x2a': minor +--- + +The user can newly update the project migration plan in an external flow and then let the X2A resync the module list changes. diff --git a/workspaces/x2a/plugins/x2a-backend/README.md b/workspaces/x2a/plugins/x2a-backend/README.md index d6cd492716..bda5c415bb 100644 --- a/workspaces/x2a/plugins/x2a-backend/README.md +++ b/workspaces/x2a/plugins/x2a-backend/README.md @@ -196,8 +196,7 @@ The plugin now properly handles AAP credentials from both system-wide configurat - **Fixed authentication**: All project lookup operations now properly pass user credentials for authorization checks - **Consistent permissions**: The following endpoints now correctly validate user permissions: - - `POST /projects/:projectId/run` (init phase) - - `POST /projects/:projectId/modules` (create module) + - `POST /projects/:projectId/run` (init phase, including migration plan resync via `refresh: true`) - `POST /projects/:projectId/modules/:moduleId/run` (analyze/migrate/publish phases) ## API Usage Examples diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2026051900_add_modules_removed_at.ts similarity index 55% rename from workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts rename to workspaces/x2a/plugins/x2a-backend/migrations/2026051900_add_modules_removed_at.ts index e94e0e872d..ac990452d1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/migrations/2026051900_add_modules_removed_at.ts @@ -14,20 +14,26 @@ * limitations under the License. */ -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** +import type { Knex } from 'knex'; /** + * Adds removed_at column to modules table for soft-delete support. + * + * @public + */ +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('modules', table => { + table.timestamp('removed_at').nullable(); + }); +} + +/** + * Drops the removed_at column from modules table. + * * @public */ -export interface ProjectsProjectIdModulesPostRequest { - /** - * Module name - */ - name: string; - /** - * Path to the module in the source repository - */ - sourcePath: string; +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('modules', table => { + table.dropColumn('removed_at'); + }); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts b/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts index 62ebec2ce4..9d12df7286 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts @@ -244,6 +244,9 @@ export interface MockRouterDeps { createModule: jest.Mock; getModule: jest.Mock; deleteModule: jest.Mock; + softDeleteModule: jest.Mock; + restoreModule: jest.Mock; + updateModule: jest.Mock; listJobs: jest.Mock; listJobsForProject: jest.Mock; listJobsForModule: jest.Mock; @@ -289,6 +292,9 @@ export function createMockRouterDeps(): MockRouterDeps { createModule: jest.fn().mockResolvedValue({ id: 'mock-module-id' }), getModule: jest.fn(), deleteModule: jest.fn().mockResolvedValue(1), + softDeleteModule: jest.fn().mockResolvedValue(1), + restoreModule: jest.fn().mockResolvedValue(1), + updateModule: jest.fn().mockResolvedValue(1), listJobs: jest.fn(), listJobsForProject: jest.fn(), listJobsForModule: jest.fn(), diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts index ff3a14e7d8..764a60cb0c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts @@ -120,6 +120,11 @@ const getX2aDatabaseServiceMock = (): typeof x2aDatabaseServiceRef.T => ({ // modules createModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), deleteModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), + softDeleteModule: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + restoreModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), + updateModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), listModules: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), getModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), // jobs diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifactsActions.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifactsActions.test.ts index d9c33cee36..35dc706c40 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifactsActions.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifactsActions.test.ts @@ -150,7 +150,7 @@ describe('collectArtifacts routes (actions & signatures)', () => { mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules); mockDeps.x2aDatabase.createModule.mockResolvedValue({ id: randomUUID() }); - mockDeps.x2aDatabase.deleteModule.mockResolvedValue(1); + mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1); const requestBody = { status: 'success', jobId, artifacts }; const signature = signRequestBody(requestBody, callbackToken); @@ -168,12 +168,75 @@ describe('collectArtifacts routes (actions & signatures)', () => { projectId, technology: undefined, }); - expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledTimes(1); - expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledWith({ + expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1); + expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({ id: 'existing-2', }); }); + it('should soft-delete a module in success state when removed from metadata during resync', async () => { + const metadataModules = [ + { name: 'still-in-plan', path: '/cookbooks/still' }, + ]; + const artifacts = [ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]; + + const existingModules = [ + { + id: 'module-success', + name: 'removed-from-plan', + sourcePath: '/cookbooks/removed', + projectId, + status: 'success', + analyze: { id: 'job-a', status: 'success', phase: 'analyze' }, + }, + { + id: 'module-kept', + name: 'still-in-plan', + sourcePath: '/cookbooks/still', + projectId, + }, + ]; + + const job: Job & { callbackToken?: string } = { + id: jobId, + projectId, + moduleId: undefined, + phase: 'init', + status: 'running', + startedAt: new Date(), + k8sJobName, + callbackToken, + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules); + mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1); + + const requestBody = { status: 'success', jobId, artifacts }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post(`/projects/${projectId}/collectArtifacts?phase=init`) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1); + expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({ + id: 'module-success', + }); + expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(mockDeps.x2aDatabase.restoreModule).not.toHaveBeenCalled(); + }); + it('should not trigger phase actions when no project_metadata artifact', async () => { const artifacts = [ { @@ -291,6 +354,100 @@ describe('collectArtifacts routes (actions & signatures)', () => { expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled(); expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled(); }); + + it('should restore previously soft-deleted modules when they reappear in metadata', async () => { + const metadataModules = [ + { name: 'restored-module', path: '/cookbooks/restored' }, + ]; + const artifacts = [ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]; + + const existingModules = [ + { + id: 'existing-removed', + name: 'restored-module', + sourcePath: '/cookbooks/restored', + projectId, + removedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + + const job: Job & { callbackToken?: string } = { + id: jobId, + projectId, + moduleId: undefined, + phase: 'init', + status: 'running', + startedAt: new Date(), + k8sJobName, + callbackToken, + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules); + mockDeps.x2aDatabase.restoreModule.mockResolvedValue(1); + + const requestBody = { status: 'success', jobId, artifacts }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post(`/projects/${projectId}/collectArtifacts?phase=init`) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1); + expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledWith({ + id: 'existing-removed', + }); + expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); + + it('should handle malformed project_metadata JSON gracefully', async () => { + const artifacts = [ + { + id: randomUUID(), + type: 'project_metadata', + value: 'not valid json {{{', + }, + ]; + + const job: Job & { callbackToken?: string } = { + id: jobId, + projectId, + moduleId: undefined, + phase: 'init', + status: 'running', + startedAt: new Date(), + k8sJobName, + callbackToken, + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + + const requestBody = { status: 'success', jobId, artifacts }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post(`/projects/${projectId}/collectArtifacts?phase=init`) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled(); + expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); }); describe('graceful failure', () => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts index 3b816c08b4..3ac63d5030 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts @@ -540,69 +540,4 @@ describe('createRouter – modules', () => { LONG_TEST_TIMEOUT, ); }); - - describe('POST /projects/:projectId/modules', () => { - it.each(supportedDatabaseIds)( - 'should create a module and return 201 - %p', - async databaseId => { - const { client } = await createDatabase(databaseId); - const app = await createApp(client); - - const createProjectRes = await request(app) - .post('/projects') - .send(mockInputProject); - expect(createProjectRes.status).toBe(200); - const projectId = createProjectRes.body.id; - - const response = await request(app) - .post(`/projects/${projectId}/modules`) - .send({ name: 'New Module', sourcePath: '/src/module' }); - - expect(response.status).toBe(201); - expect(response.body).toMatchObject({ - name: 'New Module', - sourcePath: '/src/module', - projectId, - }); - expect(response.body.id).toBeDefined(); - }, - LONG_TEST_TIMEOUT, - ); - - it.each(supportedDatabaseIds)( - 'should return 404 when project does not exist - %p', - async databaseId => { - const { client } = await createDatabase(databaseId); - const app = await createApp(client); - - const response = await request(app) - .post(`/projects/${nonExistentId}/modules`) - .send({ name: 'Module', sourcePath: '/path' }); - - expect(response.status).toBe(404); - expect(response.body.error.message).toContain('not found'); - }, - ); - - it.each(supportedDatabaseIds)( - 'should return 400 when body is invalid - %p', - async databaseId => { - const { client } = await createDatabase(databaseId); - const app = await createApp(client); - - const createProjectRes = await request(app) - .post('/projects') - .send(mockInputProject); - expect(createProjectRes.status).toBe(200); - const projectId = createProjectRes.body.id; - - const response = await request(app) - .post(`/projects/${projectId}/modules`) - .send({ name: 'Only name' }); - - expect(response.status).toBe(400); - expect(response.body.error.name).toBe('InputError'); - }, - ); - }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts index c18233a806..138de6535a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts @@ -69,7 +69,10 @@ export function registerModuleRoutes( catalog, }); - const modules = await x2aDatabase.listModules({ projectId }); + const modules = await x2aDatabase.listModules({ + projectId, + includeRemoved: true, + }); await listModulesWithReconciledStatuses(modules, { kubeService, x2aDatabase, @@ -125,58 +128,6 @@ export function registerModuleRoutes( res.json(module); }); - // TODO: This is a TEMPORARY endpoint for testing only. - // According to the ADR (lines 202-213), this endpoint should sync modules by: - // 1. Fetching the migration project plan from the target repo - // 2. Parsing it via LLM to extract the list of modules - // 3. Generating moduleIds for new ones and deleting missing modules - // This simple CRUD implementation allows testing the job infrastructure - // until the init phase integration is complete. - router.post( - '/projects/:projectId/modules', - async (req: express.Request, res: express.Response) => { - const endpoint = - 'Temporary endpoint - for testing only. POST /projects/:projectId/modules'; - const { projectId } = req.params; - logger.info(`${endpoint} request received: projectId=${projectId}`); - - await useEnforceProjectPermissions({ - req, - readOnly: false, - projectId, - x2aDatabase, - httpAuth, - permissionsSvc, - catalog, - }); - - // Validate request body - const createModuleRequestSchema = z.object({ - name: z.string(), - sourcePath: z.string(), - }); - - const parsedBody = createModuleRequestSchema - .passthrough() - .safeParse(req.body); - if (!parsedBody.success) { - throw new InputError(`Invalid body ${endpoint}: ${parsedBody.error}`); - } - const { name, sourcePath } = parsedBody.data; - - // Create module - const module = await x2aDatabase.createModule({ - name, - sourcePath, - projectId, - }); - - logger.info(`Module created: moduleId=${module.id}, name=${module.name}`); - - res.status(201).json(module); - }, - ); - router.post( '/projects/:projectId/modules/:moduleId/run', async (req: express.Request, res: express.Response) => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.test.ts index 85b50a49b1..3fd5b250ab 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.test.ts @@ -29,6 +29,8 @@ describe('phaseActions', () => { listModules: jest.Mock; createModule: jest.Mock; deleteModule: jest.Mock; + softDeleteModule: jest.Mock; + restoreModule: jest.Mock; }; } { return { @@ -38,6 +40,9 @@ describe('phaseActions', () => { listModules: jest.fn().mockResolvedValue([]), createModule: jest.fn().mockResolvedValue({ id: randomUUID() }), deleteModule: jest.fn().mockResolvedValue(1), + softDeleteModule: jest.fn().mockResolvedValue(1), + restoreModule: jest.fn().mockResolvedValue(1), + updateModule: jest.fn().mockResolvedValue(1), } as any, logger: mockServices.logger.mock(), }; @@ -84,7 +89,7 @@ describe('phaseActions', () => { }); }); - it('should sync correctly: add new, remove missing, keep matching', async () => { + it('should sync correctly: add new, soft-delete missing, keep matching', async () => { const metadataModules = [ { name: 'kept', path: '/cookbooks/kept' }, { name: 'added', path: '/cookbooks/added' }, @@ -109,8 +114,8 @@ describe('phaseActions', () => { await executePhaseActions('init', context); - expect(context.x2aDatabase.deleteModule).toHaveBeenCalledTimes(1); - expect(context.x2aDatabase.deleteModule).toHaveBeenCalledWith({ + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({ id: 'id-2', }); expect(context.x2aDatabase.createModule).toHaveBeenCalledTimes(1); @@ -122,6 +127,140 @@ describe('phaseActions', () => { }); }); + it('should update sourcePath when module name matches but path changed', async () => { + const metadataModules = [ + { name: 'cookbook-a', path: '/cookbooks/a-new', technology: 'chef' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'cookbook-a', + sourcePath: '/cookbooks/a-old', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.updateModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-1', + sourcePath: '/cookbooks/a-new', + }); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); + + it('should restore previously soft-deleted module when it reappears in metadata', async () => { + const metadataModules = [ + { name: 'restored-module', path: '/cookbooks/restored' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'restored-module', + sourcePath: '/cookbooks/restored', + projectId, + removedAt: '2026-01-01T00:00:00.000Z', + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledWith({ + id: 'id-1', + }); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); + + it('should restore and update a soft-deleted module when it reappears with a different path', async () => { + const metadataModules = [ + { + name: 'restored-module', + path: '/cookbooks/new-path', + technology: 'ansible', + }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'restored-module', + sourcePath: '/cookbooks/old-path', + projectId, + technology: 'chef' as const, + removedAt: '2026-01-01T00:00:00.000Z', + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledWith({ + id: 'id-1', + }); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-1', + sourcePath: '/cookbooks/new-path', + technology: 'ansible', + }); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); + + it('should not soft-delete already removed modules', async () => { + const metadataModules = [{ name: 'new-only', path: '/cookbooks/new' }]; + const existingModules = [ + { + id: 'id-1', + name: 'already-removed', + sourcePath: '/cookbooks/old', + projectId, + removedAt: '2026-01-01T00:00:00.000Z', + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.createModule).toHaveBeenCalledTimes(1); + }); + it('should handle empty metadata array', async () => { const existingModules = [ { @@ -142,8 +281,8 @@ describe('phaseActions', () => { await executePhaseActions('init', context); - expect(context.x2aDatabase.deleteModule).toHaveBeenCalledTimes(1); - expect(context.x2aDatabase.deleteModule).toHaveBeenCalledWith({ + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({ id: 'id-1', }); expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); @@ -162,7 +301,7 @@ describe('phaseActions', () => { expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); - expect(context.x2aDatabase.deleteModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); }); it('should handle no artifacts at all', async () => { @@ -173,6 +312,492 @@ describe('phaseActions', () => { expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); }); + + it('should handle invalid JSON in metadata artifact gracefully', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: 'not valid json {{', + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to parse project_metadata artifact, skipping module sync', + ), + ); + }); + + it('should handle non-array JSON (object) in metadata artifact', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify({ name: 'not-an-array', path: '/foo' }), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not an array'), + ); + }); + + it('should handle non-array JSON (null) in metadata artifact', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: 'null', + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not an array'), + ); + }); + + it('should handle non-array JSON (string) in metadata artifact', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: '"just a string"', + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.listModules).not.toHaveBeenCalled(); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not an array'), + ); + }); + + it('should log warning for unrecognized technology and store undefined', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'unknown-tech' }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith({ + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: undefined, + }); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Unrecognized source technology "unknown-tech"', + ), + ); + }); + + it('should normalize technology aliases (e.g. legacy-ansible -> ansible)', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'legacy-ansible' }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith({ + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: 'ansible', + }); + expect(context.logger.warn).not.toHaveBeenCalled(); + }); + + it('should update technology when it changes from one valid value to another', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'ansible' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-1', + technology: 'ansible', + }); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + }); + + it('should update technology when metadata omits it (valid -> undefined)', async () => { + const metadataModules = [{ name: 'mod-a', path: '/path/a' }]; + const existingModules = [ + { + id: 'id-1', + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-1', + technology: undefined, + }); + }); + + it('should update technology when metadata adds it (undefined -> valid)', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'powershell' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: undefined, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-1', + technology: 'powershell', + }); + }); + + it('should not update when neither path nor technology changed', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'chef' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.updateModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + }); + + it('should match module names after trimming whitespace', async () => { + const metadataModules = [ + { name: ' mod-a ', path: '/path/a', technology: 'chef' }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'mod-a', + sourcePath: '/path/a', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.softDeleteModule).not.toHaveBeenCalled(); + expect(context.x2aDatabase.updateModule).not.toHaveBeenCalled(); + }); + + it('should handle complex scenario: add, remove, restore, and update in one call', async () => { + const metadataModules = [ + { name: 'kept-same', path: '/path/same', technology: 'chef' }, + { name: 'updated-path', path: '/path/new', technology: 'chef' }, + { name: 'restored', path: '/path/restored', technology: 'ansible' }, + { + name: 'brand-new', + path: '/path/brand-new', + technology: 'powershell', + }, + ]; + const existingModules = [ + { + id: 'id-1', + name: 'kept-same', + sourcePath: '/path/same', + projectId, + technology: 'chef' as const, + }, + { + id: 'id-2', + name: 'updated-path', + sourcePath: '/path/old', + projectId, + technology: 'chef' as const, + }, + { + id: 'id-3', + name: 'restored', + sourcePath: '/path/restored', + projectId, + technology: 'chef' as const, + removedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'id-4', + name: 'to-be-removed', + sourcePath: '/path/removed', + projectId, + technology: 'chef' as const, + }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({ + id: 'id-4', + }); + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.restoreModule).toHaveBeenCalledWith({ + id: 'id-3', + }); + expect(context.x2aDatabase.createModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith({ + name: 'brand-new', + sourcePath: '/path/brand-new', + projectId, + technology: 'powershell', + }); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledTimes(2); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-2', + sourcePath: '/path/new', + }); + expect(context.x2aDatabase.updateModule).toHaveBeenCalledWith({ + id: 'id-3', + technology: 'ansible', + }); + }); + + it('should call listModules with includeRemoved: true', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([{ name: 'mod', path: '/p' }]), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.listModules).toHaveBeenCalledWith({ + projectId, + includeRemoved: true, + }); + }); + + it('should propagate database errors from listModules', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([{ name: 'mod', path: '/p' }]), + }, + ]); + context.x2aDatabase.listModules.mockRejectedValue( + new Error('DB connection lost'), + ); + + await expect(executePhaseActions('init', context)).rejects.toThrow( + 'DB connection lost', + ); + }); + + it('should propagate database errors from createModule', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([{ name: 'mod', path: '/p' }]), + }, + ]); + context.x2aDatabase.createModule.mockRejectedValue( + new Error('Unique constraint violation'), + ); + + await expect(executePhaseActions('init', context)).rejects.toThrow( + 'Unique constraint violation', + ); + }); + + it('should propagate database errors from softDeleteModule', async () => { + const existingModules = [ + { id: 'id-1', name: 'to-remove', sourcePath: '/p', projectId }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([]), + }, + ]); + context.x2aDatabase.listModules.mockResolvedValue(existingModules); + context.x2aDatabase.softDeleteModule.mockRejectedValue( + new Error('Delete failed'), + ); + + await expect(executePhaseActions('init', context)).rejects.toThrow( + 'Delete failed', + ); + }); + + it('should handle duplicate module names in metadata (creates both)', async () => { + const metadataModules = [ + { name: 'dup', path: '/path/first' }, + { name: 'dup', path: '/path/second' }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).toHaveBeenCalledTimes(2); + }); + + it('should use first artifact of type project_metadata when multiple exist', async () => { + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([{ name: 'first', path: '/first' }]), + }, + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify([{ name: 'second', path: '/second' }]), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).toHaveBeenCalledTimes(1); + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith({ + name: 'first', + sourcePath: '/first', + projectId, + technology: undefined, + }); + }); + + it('should normalize technology case-insensitively', async () => { + const metadataModules = [ + { name: 'mod-a', path: '/path/a', technology: 'CHEF' }, + { name: 'mod-b', path: '/path/b', technology: 'Ansible' }, + ]; + const context = createMockContext([ + { + id: randomUUID(), + type: 'project_metadata', + value: JSON.stringify(metadataModules), + }, + ]); + + await executePhaseActions('init', context); + + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mod-a', technology: 'chef' }), + ); + expect(context.x2aDatabase.createModule).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mod-b', technology: 'ansible' }), + ); + }); }); describe('non-init phases', () => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.ts b/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.ts index 6afc76c47c..8b30afe685 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/phaseActions.ts @@ -52,7 +52,14 @@ class InitPhaseAction implements PhaseAction { let metadataModules: MetadataModule[]; try { - metadataModules = JSON.parse(metadataArtifact.value); + const parsed = JSON.parse(metadataArtifact.value); + if (!Array.isArray(parsed)) { + context.logger.warn( + `project_metadata artifact is not an array, skipping module sync`, + ); + return; + } + metadataModules = parsed; } catch (error) { context.logger.warn( `Failed to parse project_metadata artifact, skipping module sync: ${error instanceof Error ? error.message : String(error)}`, @@ -63,36 +70,126 @@ class InitPhaseAction implements PhaseAction { await this.syncModules(context, metadataModules); } + private normalizeTechWithWarning( + logger: LoggerService, + technology: string | undefined, + moduleName: string, + ): ReturnType { + const normalized = normalizeSourceTechnology(technology); + if (technology && !normalized) { + logger.warn( + `Unrecognized source technology "${technology}" for module "${moduleName}" - storing as undefined`, + ); + } + return normalized; + } + + private classifyModuleChanges( + logger: LoggerService, + existingModules: Awaited< + ReturnType + >, + metadataModules: MetadataModule[], + projectId: string, + ) { + const existingByName = new Map( + existingModules.map(m => [m.name.trim(), m]), + ); + const metadataNames = new Set(metadataModules.map(m => m.name.trim())); + + logger.debug( + `syncModules for project ${projectId}: existingNames=[${[...existingByName.keys()].join(', ')}], metadataNames=[${[...metadataNames].join(', ')}]`, + ); + + // Modules in DB but not in metadata: soft-delete + const toRemove = existingModules.filter( + m => !metadataNames.has(m.name.trim()) && !m.removedAt, + ); + + // Modules in metadata but not in DB (or previously soft-deleted): add or restore + const toAdd: MetadataModule[] = []; + const toRestore: typeof existingModules = []; + const toUpdate: Array<{ + id: string; + sourcePath?: string; + technology?: ReturnType; + }> = []; + + for (const mm of metadataModules) { + const existing = existingByName.get(mm.name.trim()); + if (existing) { + const technology = this.normalizeTechWithWarning( + logger, + mm.technology, + mm.name, + ); + const pathChanged = existing.sourcePath !== mm.path; + const techChanged = existing.technology !== technology; + + if (existing.removedAt) { + toRestore.push(existing); + } + + if (pathChanged || techChanged) { + toUpdate.push({ + id: existing.id, + ...(pathChanged ? { sourcePath: mm.path } : {}), + ...(techChanged ? { technology } : {}), + }); + } + } else { + toAdd.push(mm); + } + } + + return { toRemove, toAdd, toRestore, toUpdate }; + } + private async syncModules( context: PhaseActionContext, metadataModules: MetadataModule[], ): Promise { const { projectId, x2aDatabase, logger } = context; - const existingModules = await x2aDatabase.listModules({ projectId }); - const existingNames = new Set(existingModules.map(m => m.name)); - const metadataNames = new Set(metadataModules.map(m => m.name)); + const existingModules = await x2aDatabase.listModules({ + projectId, + includeRemoved: true, + }); - const toAdd = metadataModules.filter(m => !existingNames.has(m.name)); - const toRemove = existingModules.filter(m => !metadataNames.has(m.name)); + logger.debug( + `syncModules for project ${projectId}: existingNames=[${existingModules.map(m => m.name).join(', ')}], metadataNames=[${metadataModules.map(m => m.name).join(', ')}]`, + ); - await Promise.all( - toRemove.map(m => { + const { toRemove, toAdd, toRestore, toUpdate } = this.classifyModuleChanges( + logger, + existingModules, + metadataModules, + projectId, + ); + + // Persist the changes + await Promise.all([ + ...toRemove.map(m => { logger.info( - `Removing module "${m.name}" (${m.id}) from project ${projectId}`, + `Soft-deleting module "${m.name}" (${m.id}) from project ${projectId}`, ); - return x2aDatabase.deleteModule({ id: m.id }); + return x2aDatabase.softDeleteModule({ id: m.id }); }), - ); + ...toRestore.map(m => { + logger.info( + `Restoring previously removed module "${m.name}" (${m.id}) in project ${projectId}`, + ); + return x2aDatabase.restoreModule({ id: m.id }); + }), + ]); await Promise.all( toAdd.map(m => { - const technology = normalizeSourceTechnology(m.technology); - if (m.technology && !technology) { - logger.warn( - `Unrecognized source technology "${m.technology}" for module "${m.name}" - storing as undefined`, - ); - } + const technology = this.normalizeTechWithWarning( + logger, + m.technology, + m.name, + ); logger.info( `Creating module "${m.name}" for project ${projectId} with technology ${technology}`, ); @@ -105,8 +202,17 @@ class InitPhaseAction implements PhaseAction { }), ); + await Promise.all( + toUpdate.map(({ id, sourcePath, technology }) => { + logger.info( + `Updating module (${id}) in project ${projectId}: sourcePath=${sourcePath ?? '(unchanged)'}, technology=${technology ?? '(unchanged)'}`, + ); + return x2aDatabase.updateModule({ id, sourcePath, technology }); + }), + ); + logger.info( - `Module sync complete for project ${projectId}: added=${toAdd.length}, removed=${toRemove.length}, kept=${existingModules.length - toRemove.length}`, + `Module sync complete for project ${projectId}: added=${toAdd.length}, removed=${toRemove.length}, restored=${toRestore.length}, updated=${toUpdate.length}`, ); } } diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts index 8018aee156..3cea136bc6 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts @@ -320,6 +320,7 @@ export function registerProjectRoutes( const { project, userRef } = await useEnforceProjectPermissions({ req, readOnly: false, + doEnrichment: true, projectId, x2aDatabase, permissionsSvc, @@ -351,23 +352,24 @@ export function registerProjectRoutes( }) .optional(), userPrompt: z.string().optional(), + refresh: z.boolean().optional(), }); const parsedBody = runRequestSchema.passthrough().safeParse(req.body); if (!parsedBody.success) { throw new InputError(`Invalid body ${endpoint}: ${parsedBody.error}`); } - const { sourceRepoAuth, targetRepoAuth, aapCredentials, userPrompt } = - parsedBody.data; - - // Resolve git repositories with config-based token fallback - const { sourceRepo, targetRepo } = gitRepoResolver.resolve({ - project, + const { sourceRepoAuth, targetRepoAuth, - }); + aapCredentials, + userPrompt, + refresh, + } = parsedBody.data; - // Check for existing running init job + // Check for existing running init job before refresh validation so a + // pending resync (latest init job without artifacts yet) returns 409 + // rather than 400 for missing migrationPlan on the in-flight job. const existingJobs = await x2aDatabase.listJobsForProject({ projectId }); const activeInitJobs = existingJobs.filter( job => job.phase === 'init' && JobStatus.from(job.status).isActive(), @@ -390,6 +392,19 @@ export function registerProjectRoutes( }); } + if (refresh && !project.migrationPlan) { + throw new InputError( + `Invalid body ${endpoint}: refresh requires an existing migration plan from a completed init phase`, + ); + } + + // Resolve git repositories with config-based token fallback + const { sourceRepo, targetRepo } = gitRepoResolver.resolve({ + project, + sourceRepoAuth, + targetRepoAuth, + }); + const callbackToken = CallbackToken.generate(); const job = await x2aDatabase.createJob({ projectId, @@ -426,6 +441,7 @@ export function registerProjectRoutes( aapCredentials, userPrompt, acceptedRules, + refresh, }); // Update job with k8s job name diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/resync.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/resync.test.ts new file mode 100644 index 0000000000..ece64a780f --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/router/resync.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import request from 'supertest'; + +import { + artifactsFromValues, + createApp, + createDatabase, + createService, + LONG_TEST_TIMEOUT, + mockInputProject, + nonExistentId, + supportedDatabaseIds, + tearDownDatabases, +} from '../__testUtils__'; + +async function seedMigrationPlan( + client: Awaited>['client'], + projectId: string, +): Promise { + const service = createService(client); + await service.createJob({ + projectId, + phase: 'init', + status: 'success', + artifacts: artifactsFromValues( + ['https://repo.example.com/migration-plan.md'], + 'migration_plan', + ), + }); +} + +describe('POST /projects/:projectId/run – resync (refresh=true)', () => { + afterEach(async () => { + await tearDownDatabases(); + }); + + it.each(supportedDatabaseIds)( + 'should create an init job with refresh flag and return 200 - %p', + async databaseId => { + const kubeCreateJob = jest + .fn() + .mockResolvedValue({ k8sJobName: 'resync-job-1' }); + + const { client } = await createDatabase(databaseId); + const app = await createApp(client, undefined, undefined, { + createJob: kubeCreateJob, + }); + + const createRes = await request(app) + .post('/projects') + .send(mockInputProject); + expect(createRes.status).toBe(200); + const projectId = createRes.body.id; + await seedMigrationPlan(client, projectId); + + const runRes = await request(app) + .post(`/projects/${projectId}/run`) + .send({ + sourceRepoAuth: { token: 'src-token' }, + targetRepoAuth: { token: 'tgt-token' }, + refresh: true, + }); + + expect(runRes.status).toBe(200); + expect(runRes.body.jobId).toBeDefined(); + expect(runRes.body.status).toBe('pending'); + + expect(kubeCreateJob).toHaveBeenCalledTimes(1); + expect(kubeCreateJob).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'init', + refresh: true, + projectId, + }), + ); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'should return 409 when refresh is requested while init job is already running - %p', + async databaseId => { + const kubeCreateJob = jest + .fn() + .mockResolvedValue({ k8sJobName: 'resync-job-1' }); + + const { client } = await createDatabase(databaseId); + const app = await createApp(client, undefined, undefined, { + createJob: kubeCreateJob, + }); + + const createRes = await request(app) + .post('/projects') + .send(mockInputProject); + expect(createRes.status).toBe(200); + const projectId = createRes.body.id; + await seedMigrationPlan(client, projectId); + + const resyncBody = { + sourceRepoAuth: { token: 'src-token' }, + targetRepoAuth: { token: 'tgt-token' }, + refresh: true, + }; + + const first = await request(app) + .post(`/projects/${projectId}/run`) + .send(resyncBody); + expect(first.status).toBe(200); + + const second = await request(app) + .post(`/projects/${projectId}/run`) + .send(resyncBody); + expect(second.status).toBe(409); + expect(second.body).toMatchObject({ + error: 'JobAlreadyRunning', + message: expect.stringContaining('init job is already running'), + }); + expect(kubeCreateJob).toHaveBeenCalledTimes(1); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'should return 400 when refresh is requested without migration plan - %p', + async databaseId => { + const kubeCreateJob = jest + .fn() + .mockResolvedValue({ k8sJobName: 'resync-job-1' }); + + const { client } = await createDatabase(databaseId); + const app = await createApp(client, undefined, undefined, { + createJob: kubeCreateJob, + }); + + const createRes = await request(app) + .post('/projects') + .send(mockInputProject); + expect(createRes.status).toBe(200); + const projectId = createRes.body.id; + + const runRes = await request(app) + .post(`/projects/${projectId}/run`) + .send({ + sourceRepoAuth: { token: 'src-token' }, + targetRepoAuth: { token: 'tgt-token' }, + refresh: true, + }); + + expect(runRes.status).toBe(400); + expect(kubeCreateJob).not.toHaveBeenCalled(); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'should return 404 when project does not exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const res = await request(app) + .post(`/projects/${nonExistentId}/run`) + .send({ + sourceRepoAuth: { token: 'src-token' }, + targetRepoAuth: { token: 'tgt-token' }, + refresh: true, + }); + + expect(res.status).toBe(404); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'should pass refresh=undefined when not provided in body - %p', + async databaseId => { + const kubeCreateJob = jest + .fn() + .mockResolvedValue({ k8sJobName: 'init-job-1' }); + + const { client } = await createDatabase(databaseId); + const app = await createApp(client, undefined, undefined, { + createJob: kubeCreateJob, + }); + + const createRes = await request(app) + .post('/projects') + .send(mockInputProject); + const projectId = createRes.body.id; + + await request(app) + .post(`/projects/${projectId}/run`) + .send({ + sourceRepoAuth: { token: 'src-token' }, + targetRepoAuth: { token: 'tgt-token' }, + }); + + expect(kubeCreateJob).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'init', + refresh: undefined, + }), + ); + }, + LONG_TEST_TIMEOUT, + ); +}); diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 5bf71c0782..4086c0df65 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -335,6 +335,9 @@ paths: userPrompt: type: string description: Optional user prompt for customizing the migration + refresh: + type: boolean + description: When true, runs init in refresh mode to resync the module list from an existing migration plan required: - sourceRepoAuth - targetRepoAuth @@ -380,48 +383,6 @@ paths: $ref: '#/components/schemas/Module' '404': description: Project not found - post: - summary: Creates a new module for a project - description: | - **TEMPORARY ENDPOINT FOR TESTING ONLY** - - This endpoint provides simple CRUD functionality to create modules for testing the job triggering infrastructure. - - According to the ADR, this endpoint should eventually sync modules by parsing the migration plan (created by the init phase). - The proper implementation will be added when the init phase integration is complete. - - TODO: Replace with proper sync logic that parses the migration plan via LLM (see ADR lines 202-213) - parameters: - - in: path - name: projectId - schema: - type: string - required: true - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Module name - sourcePath: - type: string - description: Path to the module in the source repository - required: - - name - - sourcePath - responses: - '201': - description: Module created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/Module' - '404': - description: Project not found /projects/{projectId}/modules/{moduleId}: get: @@ -792,6 +753,10 @@ components: errorDetails: type: string description: Detailed error information if the module failed to execute + removedAt: + type: string + format: date-time + description: Timestamp when the module was soft-deleted during a migration plan resync. Null/missing for active modules. required: - id - name @@ -816,12 +781,14 @@ components: fails but a former migrate already passed), the modules status should not change (is still based on the last phase). The pending state is used for modules that have no jobs yet. If a module is in pending state for long time, it can refer to an issue with the OCP setup. + The removed state indicates the module was soft-deleted during a migration plan resync. enum: - pending - running - success - error - cancelled + - removed ProjectStatusState: type: string @@ -866,6 +833,9 @@ components: cancelled: type: integer description: Number of modules in cancelled state (last job was cancelled by the user) + removed: + type: integer + description: Number of soft-deleted modules (excluded from other counts) required: - total - finished @@ -874,6 +844,7 @@ components: - running - error - cancelled + - removed ProjectStatus: type: object diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 3cafcf2f97..179a222e78 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -30,7 +30,6 @@ import { ProjectsProjectIdCollectArtifactsPostRequest } from '../models/Projects import { ProjectsProjectIdDelete200Response } from '../models/ProjectsProjectIdDelete200Response.model'; import { ProjectsProjectIdModulesModuleIdCancelPostRequest } from '../models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model'; import { ProjectsProjectIdModulesModuleIdRunPostRequest } from '../models/ProjectsProjectIdModulesModuleIdRunPostRequest.model'; -import { ProjectsProjectIdModulesPostRequest } from '../models/ProjectsProjectIdModulesPostRequest.model'; import { ProjectsProjectIdPatchRequest } from '../models/ProjectsProjectIdPatchRequest.model'; import { ProjectsProjectIdRunPost200Response } from '../models/ProjectsProjectIdRunPost200Response.model'; import { ProjectsProjectIdRunPostRequest } from '../models/ProjectsProjectIdRunPostRequest.model'; @@ -161,16 +160,6 @@ export type ProjectsProjectIdModulesModuleIdRunPost = { body: ProjectsProjectIdModulesModuleIdRunPostRequest; response: ProjectsProjectIdRunPost200Response | void; }; -/** - * @public - */ -export type ProjectsProjectIdModulesPost = { - path: { - projectId: string; - }; - body: ProjectsProjectIdModulesPostRequest; - response: Module | void; -}; /** * @public */ @@ -256,8 +245,6 @@ export type EndpointMap = { '#post|/projects/{projectId}/modules/{moduleId}/run': ProjectsProjectIdModulesModuleIdRunPost; - '#post|/projects/{projectId}/modules': ProjectsProjectIdModulesPost; - '#patch|/projects/{projectId}': ProjectsProjectIdPatch; '#post|/projects/{projectId}/run': ProjectsProjectIdRunPost; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts index 1eb85ee29a..9ff1a03933 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts @@ -50,4 +50,8 @@ export interface Module { * Detailed error information if the module failed to execute */ errorDetails?: string; + /** + * Timestamp when the module was soft-deleted during a migration plan resync. Null/missing for active modules. + */ + removedAt?: Date; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts index 051ff4e724..4599c18b08 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -26,4 +26,5 @@ export type ModuleStatus = | 'running' | 'success' | 'error' - | 'cancelled'; + | 'cancelled' + | 'removed'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts index 98fd18b249..72fc721563 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts @@ -50,4 +50,8 @@ export interface ModulesStatusSummary { * Number of modules in cancelled state (last job was cancelled by the user) */ cancelled: number; + /** + * Number of soft-deleted modules (excluded from other counts) + */ + removed: number; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts index d4bccfc1fe..84c3afb68a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts @@ -31,4 +31,8 @@ export interface ProjectsProjectIdRunPostRequest { * Optional user prompt for customizing the migration */ userPrompt?: string; + /** + * When true, runs init in refresh mode to resync the module list from an existing migration plan + */ + refresh?: boolean; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts index 6395604fe2..320cc999ef 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts @@ -36,7 +36,6 @@ export * from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; export * from '../models/ProjectsProjectIdDelete200Response.model'; export * from '../models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model'; export * from '../models/ProjectsProjectIdModulesModuleIdRunPostRequest.model'; -export * from '../models/ProjectsProjectIdModulesPostRequest.model'; export * from '../models/ProjectsProjectIdPatchRequest.model'; export * from '../models/ProjectsProjectIdRunPost200Response.model'; export * from '../models/ProjectsProjectIdRunPostRequest.model'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 384bec2e73..4b0210b351 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -527,6 +527,10 @@ export const spec = { "userPrompt": { "type": "string", "description": "Optional user prompt for customizing the migration" + }, + "refresh": { + "type": "boolean", + "description": "When true, runs init in refresh mode to resync the module list from an existing migration plan" } }, "required": [ @@ -602,59 +606,6 @@ export const spec = { "description": "Project not found" } } - }, - "post": { - "summary": "Creates a new module for a project", - "description": "**TEMPORARY ENDPOINT FOR TESTING ONLY**\n\nThis endpoint provides simple CRUD functionality to create modules for testing the job triggering infrastructure.\n\nAccording to the ADR, this endpoint should eventually sync modules by parsing the migration plan (created by the init phase).\nThe proper implementation will be added when the init phase integration is complete.\n\nTODO: Replace with proper sync logic that parses the migration plan via LLM (see ADR lines 202-213)\n", - "parameters": [ - { - "in": "path", - "name": "projectId", - "schema": { - "type": "string" - }, - "required": true - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Module name" - }, - "sourcePath": { - "type": "string", - "description": "Path to the module in the source repository" - } - }, - "required": [ - "name", - "sourcePath" - ] - } - } - } - }, - "responses": { - "201": { - "description": "Module created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Module" - } - } - } - }, - "404": { - "description": "Project not found" - } - } } }, "/projects/{projectId}/modules/{moduleId}": { @@ -1176,6 +1127,11 @@ export const spec = { "errorDetails": { "type": "string", "description": "Detailed error information if the module failed to execute" + }, + "removedAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the module was soft-deleted during a migration plan resync. Null/missing for active modules." } }, "required": [ @@ -1197,13 +1153,14 @@ export const spec = { }, "ModuleStatus": { "type": "string", - "description": "Module status is the status of the last job of its most-advanced phase.\nIf the most-advanced phase's job was cancelled, the module status is cancelled.\nIf a later retrigger for an earlier phase fails (e.g. when retrigger on analyze\nfails but a former migrate already passed), the modules status should not change (is still based on the last phase).\nThe pending state is used for modules that have no jobs yet. If a module\nis in pending state for long time, it can refer to an issue with the OCP setup.\n", + "description": "Module status is the status of the last job of its most-advanced phase.\nIf the most-advanced phase's job was cancelled, the module status is cancelled.\nIf a later retrigger for an earlier phase fails (e.g. when retrigger on analyze\nfails but a former migrate already passed), the modules status should not change (is still based on the last phase).\nThe pending state is used for modules that have no jobs yet. If a module\nis in pending state for long time, it can refer to an issue with the OCP setup.\nThe removed state indicates the module was soft-deleted during a migration plan resync.\n", "enum": [ "pending", "running", "success", "error", - "cancelled" + "cancelled", + "removed" ] }, "ProjectStatusState": { @@ -1248,6 +1205,10 @@ export const spec = { "cancelled": { "type": "integer", "description": "Number of modules in cancelled state (last job was cancelled by the user)" + }, + "removed": { + "type": "integer", + "description": "Number of soft-deleted modules (excluded from other counts)" } }, "required": [ @@ -1257,7 +1218,8 @@ export const spec = { "pending", "running", "error", - "cancelled" + "cancelled", + "removed" ] }, "ProjectStatus": { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts index 1e757e9a72..e6ed75860e 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts @@ -764,6 +764,31 @@ describe('JobResourceBuilder', () => { ]), ); }); + + it('should include INIT_REFRESH env var when refresh is true', () => { + const paramsWithRefresh: JobCreateParams = { + ...baseParams, + refresh: true, + }; + + const job = JobResourceBuilder.buildJobSpec( + paramsWithRefresh, + mockConfig, + ); + + const container = job.spec?.template.spec?.containers![0]; + expect(container!.env).toEqual( + expect.arrayContaining([{ name: 'INIT_REFRESH', value: 'true' }]), + ); + }); + + it('should not include INIT_REFRESH env var when refresh is not set', () => { + const job = JobResourceBuilder.buildJobSpec(baseParams, mockConfig); + + const container = job.spec?.template.spec?.containers![0]; + const envNames = container!.env!.map(e => e.name); + expect(envNames).not.toContain('INIT_REFRESH'); + }); }); describe('Command generation for init phase', () => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts index 33e42d4aad..92ea675b6b 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts @@ -365,6 +365,14 @@ export class JobResourceBuilder { }, ] : []), + ...(params.refresh + ? [ + { + name: 'INIT_REFRESH', + value: 'true', + }, + ] + : []), ...(hasRules ? [ { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index f698f47091..f30b9d2219 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -57,6 +57,23 @@ import { migrate } from '../dbMigrate'; import { maxConcurrency } from '../../utils'; import { calculateProjectStatus } from './projectStatus'; +function enrichModuleWithJobStatus( + module: Module, + lastJobs: { + analyze?: Job; + migrate?: Job; + publish?: Job; + }, +): Module { + const { status, errorDetails } = calculateModuleStatus(lastJobs); + return { + ...module, + ...lastJobs, + status: module.removedAt ? 'removed' : status, + errorDetails: module.removedAt ? undefined : errorDetails, + }; +} + export class X2ADatabaseService implements X2ADatabaseServiceApi { readonly #logger: LoggerService; readonly #projectOps: ProjectOperations; @@ -90,7 +107,7 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { const lastInitJob = initJob[0]; project.status = calculateProjectStatus( - await this.listModules({ projectId }), + await this.listModules({ projectId, includeRemoved: true }), lastInitJob, ); @@ -289,6 +306,7 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { name: string; sourcePath: string; projectId: string; + technology?: Module['technology']; }): Promise { return this.#moduleOps.createModule(module); } @@ -329,20 +347,27 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { module.migrate = removeSensitiveFromJob(lastMigrateJobsOfModule[0]); module.publish = removeSensitiveFromJob(lastPublishJobsOfModule[0]); - const { status, errorDetails } = calculateModuleStatus({ + return enrichModuleWithJobStatus(module, { analyze: module.analyze, migrate: module.migrate, publish: module.publish, }); - module.status = status; - module.errorDetails = errorDetails; } return module; } - async listModules({ projectId }: { projectId: string }): Promise { - const modules = await this.#moduleOps.listModules({ projectId }); + async listModules({ + projectId, + includeRemoved, + }: { + projectId: string; + includeRemoved?: boolean; + }): Promise { + const modules = await this.#moduleOps.listModules({ + projectId, + includeRemoved, + }); // TODO: This can be optimized by using a single query to list all jobs for all modules. const lastAnalyzeJobsOfModules = await Promise.all( modules.map(module => @@ -386,20 +411,36 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { lastPublishJobsOfModules[idxModule][0], ); const lastJobs = { analyze, migrate: migrateJob, publish }; - return { - ...module, - ...lastJobs, - ...calculateModuleStatus(lastJobs), - }; + return enrichModuleWithJobStatus(module, lastJobs); }); return response; } + async updateModule({ + id, + sourcePath, + technology, + }: { + id: string; + sourcePath?: string; + technology?: Module['technology']; + }): Promise { + return this.#moduleOps.updateModule({ id, sourcePath, technology }); + } + async deleteModule({ id }: { id: string }): Promise { return this.#moduleOps.deleteModule({ id }); } + async softDeleteModule({ id }: { id: string }): Promise { + return this.#moduleOps.softDeleteModule({ id }); + } + + async restoreModule({ id }: { id: string }): Promise { + return this.#moduleOps.restoreModule({ id }); + } + // Jobs async createJob(job: CreateJobInput): Promise { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts index 0973987121..c81a8b8ff6 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts @@ -62,6 +62,9 @@ export function mapRowToModule(row: Record): Module { sourcePath: row.source_path as string, technology: (row.technology as SourceTechnology) || undefined, projectId: row.project_id as string, + removedAt: row.removed_at + ? new Date(row.removed_at as string | Date) + : undefined, }; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/moduleOperations.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/moduleOperations.ts index 6927bb012e..5100ac691b 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/moduleOperations.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/moduleOperations.ts @@ -68,14 +68,26 @@ export class ModuleOperations { return row ? mapRowToModule(row as Record) : undefined; } - async listModules({ projectId }: { projectId: string }): Promise { + async listModules({ + projectId, + includeRemoved, + }: { + projectId: string; + includeRemoved?: boolean; + }): Promise { this.#logger.info(`listModules called for projectId: ${projectId}`); - const rows = await this.#dbClient('modules') + let query = this.#dbClient('modules') .where('project_id', projectId) .select('*') .orderBy('name', 'asc'); + if (!includeRemoved) { + query = query.whereNull('removed_at'); + } + + const rows = await query; + const modules: Module[] = rows.map((row: Record) => mapRowToModule(row), ); @@ -102,4 +114,75 @@ export class ModuleOperations { return deletedCount; } + + async softDeleteModule({ id }: { id: string }): Promise { + this.#logger.info(`softDeleteModule called for id: ${id}`); + + const updatedCount = await this.#dbClient('modules') + .where('id', id) + .whereNull('removed_at') + .update({ removed_at: new Date() }); + + if (updatedCount === 0) { + this.#logger.warn( + `No module found with id: ${id} (or already soft-deleted)`, + ); + } else { + this.#logger.info(`Soft-deleted module with id: ${id}`); + } + + return updatedCount; + } + + async restoreModule({ id }: { id: string }): Promise { + this.#logger.info(`restoreModule called for id: ${id}`); + + const updatedCount = await this.#dbClient('modules') + .where('id', id) + .update({ removed_at: null }); + + if (updatedCount === 0) { + this.#logger.warn(`No module found with id: ${id}`); + } else { + this.#logger.info(`Restored module with id: ${id}`); + } + + return updatedCount; + } + + async updateModule({ + id, + sourcePath, + technology, + }: { + id: string; + sourcePath?: string; + technology?: SourceTechnology; + }): Promise { + const updates: Record = {}; + if (sourcePath !== undefined) { + updates.source_path = sourcePath; + } + if (technology !== undefined) { + updates.technology = technology; + } + + if (Object.keys(updates).length === 0) { + return 0; + } + + this.#logger.info( + `updateModule called for id: ${id}, updates: ${JSON.stringify(updates)}`, + ); + + const updatedCount = await this.#dbClient('modules') + .where('id', id) + .update(updates); + + if (updatedCount === 0) { + this.#logger.warn(`No module found with id: ${id}`); + } + + return updatedCount; + } } diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts index b52c5aeeaf..199fbb2e1b 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts @@ -572,4 +572,226 @@ describe('X2ADatabaseService – modules', () => { ); }); }); + + describe('softDeleteModule', () => { + it.each(supportedDatabaseIds)( + 'sets removed_at timestamp on the module - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Soft Delete Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'to-remove', + sourcePath: '/path', + projectId: project.id, + }); + + const result = await service.softDeleteModule({ id: mod.id }); + expect(result).toBe(1); + + const row = await client('modules').where('id', mod.id).first(); + expect(row.removed_at).not.toBeNull(); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 0 for non-existent module - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const result = await service.softDeleteModule({ id: nonExistentId }); + expect(result).toBe(0); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'soft-deleted modules are excluded from listModules by default - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'List Removed Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'will-be-removed', + sourcePath: '/path', + projectId: project.id, + }); + + await service.softDeleteModule({ id: mod.id }); + + const modules = await service.listModules({ projectId: project.id }); + expect(modules).toHaveLength(0); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'soft-deleted modules are returned by listModules when includeRemoved is true - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'List Removed Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'will-be-removed', + sourcePath: '/path', + projectId: project.id, + }); + + await service.softDeleteModule({ id: mod.id }); + + const modules = await service.listModules({ + projectId: project.id, + includeRemoved: true, + }); + expect(modules).toHaveLength(1); + expect(modules[0].removedAt).toBeDefined(); + expect(modules[0].removedAt).toBeInstanceOf(Date); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'soft-deleted module with jobs returns status removed - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Removed Status Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'removed-with-jobs', + sourcePath: '/path', + projectId: project.id, + }); + await service.createJob({ + projectId: project.id, + moduleId: mod.id, + phase: 'analyze', + status: 'success', + }); + await service.softDeleteModule({ id: mod.id }); + + const modules = await service.listModules({ + projectId: project.id, + includeRemoved: true, + }); + expect(modules).toHaveLength(1); + expect(modules[0].status).toBe('removed'); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('updateModule', () => { + it.each(supportedDatabaseIds)( + 'updates source_path and technology - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Update Module Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'to-update', + sourcePath: '/old/path', + projectId: project.id, + technology: 'chef', + }); + + const result = await service.updateModule({ + id: mod.id, + sourcePath: '/new/path', + technology: 'ansible', + }); + expect(result).toBe(1); + + const row = await client('modules').where('id', mod.id).first(); + expect(row.source_path).toBe('/new/path'); + expect(row.technology).toBe('ansible'); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('restoreModule', () => { + it.each(supportedDatabaseIds)( + 'clears removed_at on a soft-deleted module - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Restore Project', + description: 'Test', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'to-restore', + sourcePath: '/path', + projectId: project.id, + }); + + await service.softDeleteModule({ id: mod.id }); + const result = await service.restoreModule({ id: mod.id }); + expect(result).toBe(1); + + const row = await client('modules').where('id', mod.id).first(); + expect(row.removed_at).toBeNull(); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 0 for non-existent module - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const result = await service.restoreModule({ id: nonExistentId }); + expect(result).toBe(0); + }, + LONG_TEST_TIMEOUT, + ); + }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.test.ts index f8598d9846..ad924493fe 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.test.ts @@ -39,7 +39,7 @@ function job(status: Job['status'], options?: { errorDetails?: string }): Job { /** Minimal module for project status tests; only status and publish are used by calculateProjectStatus. */ function module( status: ModuleStatus, - options?: { publishStatus?: Job['status'] }, + options?: { publishStatus?: Job['status']; removedAt?: Date }, ): Module { const m: Module = { id: 'mod-id', @@ -51,9 +51,16 @@ function module( if (options?.publishStatus !== undefined) { m.publish = job(options.publishStatus); } + if (options?.removedAt !== undefined) { + m.removedAt = options.removedAt; + } return m; } +function removedModule(status: ModuleStatus): Module { + return module(status, { removedAt: new Date() }); +} + function initJob(status: Job['status']): Job { return job(status); } @@ -80,6 +87,7 @@ describe('calculateProjectStatus', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }); }); @@ -418,6 +426,7 @@ describe('calculateProjectStatus', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }); }); @@ -431,4 +440,58 @@ describe('calculateProjectStatus', () => { expect(result.modulesSummary.total).toBe(3); }); }); + + describe('removed modules', () => { + it('excludes removed modules from total and status counts', () => { + const result = calculateProjectStatus( + [module('pending'), removedModule('success'), removedModule('error')], + initJob('success'), + ); + expect(result.modulesSummary.total).toBe(1); + expect(result.modulesSummary.pending).toBe(1); + expect(result.modulesSummary.removed).toBe(2); + expect(result.modulesSummary.error).toBe(0); + }); + + it('reports removed count when all modules are removed', () => { + const result = calculateProjectStatus( + [removedModule('success'), removedModule('pending')], + initJob('success'), + ); + expect(result.state).toBe('initialized'); + expect(result.modulesSummary.total).toBe(0); + expect(result.modulesSummary.removed).toBe(2); + }); + + it('returns created state when only removed modules exist and no init job', () => { + const result = calculateProjectStatus( + [removedModule('success')], + undefined, + ); + expect(result.state).toBe('created'); + expect(result.modulesSummary.total).toBe(0); + expect(result.modulesSummary.removed).toBe(1); + }); + + it('does not let removed error modules affect project state', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + removedModule('error'), + ], + initJob('success'), + ); + expect(result.state).toBe('completed'); + expect(result.modulesSummary.error).toBe(0); + expect(result.modulesSummary.removed).toBe(1); + }); + + it('returns removed: 0 when no modules are removed', () => { + const result = calculateProjectStatus( + [module('pending'), module('running')], + initJob('success'), + ); + expect(result.modulesSummary.removed).toBe(0); + }); + }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts index 69b4707719..d2158073c5 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts @@ -28,12 +28,17 @@ export { calculateModuleStatus } from '@red-hat-developer-hub/backstage-plugin-x * Project status is calculated from its modules. * * Its "state" is accompanied by summary of its modules statuses. + * + * The removed modules are excluded from project status calculation. */ export function calculateProjectStatus( projectModules: Module[], initJob?: Job, ): ProjectStatus { - const total = projectModules.length; + const activeModules = projectModules.filter(m => !m.removedAt); + const removedCount = projectModules.length - activeModules.length; + + const total = activeModules.length; if (!initJob && total === 0) { return { state: ProjectState.CREATED.value, @@ -45,11 +50,12 @@ export function calculateProjectStatus( running: 0, error: 0, cancelled: 0, + removed: removedCount, }, }; } - const modulesWithStatus = projectModules.map(module => ({ + const modulesWithStatus = activeModules.map(module => ({ module, status: module.status ? JobStatus.from(module.status) : undefined, publishStatus: module.publish?.status @@ -95,6 +101,7 @@ export function calculateProjectStatus( running, error, cancelled, + removed: removedCount, }, }; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectsGet.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectsGet.test.ts index 3697a48481..d20384e3f8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectsGet.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectsGet.test.ts @@ -144,6 +144,57 @@ describe('X2ADatabaseService – projects (get & delete)', () => { }, ); + it.each(supportedDatabaseIds)( + 'reports removed count in modulesSummary when modules are soft-deleted - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Project with removed modules', + description: 'D', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + await service.createJob({ + projectId: project.id, + phase: 'init', + status: 'success', + artifacts: artifactsFromValues( + ['http://example.com/plan.md'], + 'migration_plan', + ), + }); + const mod = await service.createModule({ + name: 'active-module', + sourcePath: '/active', + projectId: project.id, + }); + const removed = await service.createModule({ + name: 'removed-module', + sourcePath: '/removed', + projectId: project.id, + }); + await service.softDeleteModule({ id: removed.id }); + await service.createJob({ + projectId: project.id, + moduleId: mod.id, + phase: 'analyze', + status: 'pending', + }); + + const retrieved = await service.getProject( + { projectId: project.id }, + { credentials, groupsOfUser: [] }, + ); + + expect(retrieved?.status?.modulesSummary.total).toBe(1); + expect(retrieved?.status?.modulesSummary.removed).toBe(1); + }, + ); + it.each(supportedDatabaseIds)( 'returns correct project when multiple exist - %p', async databaseId => { diff --git a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh index e6b5135680..a0bd523a00 100644 --- a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh +++ b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh @@ -297,8 +297,24 @@ case "${PHASE}" in # Run the init command # Usage: app.py init [OPTIONS] USER_REQUIREMENTS # --source-dir DIRECTORY Source directory to analyze + # --refresh Skip plan generation, only regenerate metadata USER_REQ="${USER_PROMPT:-Analyze the source configuration and create a migration plan}" - run_x2a uv run app.py init --source-dir "${SOURCE_BASE}" "${USER_REQ}" + INIT_ARGS=(--source-dir "${SOURCE_BASE}") + if [ "${INIT_REFRESH:-}" = "true" ]; then + INIT_ARGS+=(--refresh) + echo "Running in REFRESH mode (resync module list from existing plan)" + + # Copy existing migration plan from target repo so the x2a tool can find + # it in source-dir and skip plan regeneration. + if [ -f "${PROJECT_PATH}/migration-plan.md" ]; then + echo "Copying existing migration-plan.md from target to source for refresh..." + cp -v "${PROJECT_PATH}/migration-plan.md" "${SOURCE_BASE}/migration-plan.md" + else + echo "ERROR: REFRESH mode requires migration-plan.md in ${PROJECT_PATH}/ but the file was not found" >&2 + exit 1 + fi + fi + run_x2a uv run app.py init "${INIT_ARGS[@]}" "${USER_REQ}" # Copy output to target location # Note: x2a tool writes files to the source directory (--source-dir) diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index c1b61628a5..bec8c6314a 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -33,7 +33,6 @@ import { ProjectsProjectIdCollectArtifactsPostRequest } from '../models/Projects import { ProjectsProjectIdDelete200Response } from '../models/ProjectsProjectIdDelete200Response.model'; import { ProjectsProjectIdModulesModuleIdCancelPostRequest } from '../models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model'; import { ProjectsProjectIdModulesModuleIdRunPostRequest } from '../models/ProjectsProjectIdModulesModuleIdRunPostRequest.model'; -import { ProjectsProjectIdModulesPostRequest } from '../models/ProjectsProjectIdModulesPostRequest.model'; import { ProjectsProjectIdPatchRequest } from '../models/ProjectsProjectIdPatchRequest.model'; import { ProjectsProjectIdRunPost200Response } from '../models/ProjectsProjectIdRunPost200Response.model'; import { ProjectsProjectIdRunPostRequest } from '../models/ProjectsProjectIdRunPostRequest.model'; @@ -170,15 +169,6 @@ export type ProjectsProjectIdModulesModuleIdRunPost = { }; body: ProjectsProjectIdModulesModuleIdRunPostRequest; }; -/** - * @public - */ -export type ProjectsProjectIdModulesPost = { - path: { - projectId: string; - }; - body: ProjectsProjectIdModulesPostRequest; -}; /** * @public */ @@ -561,35 +551,6 @@ export class DefaultApiClient { }); } - /** - * **TEMPORARY ENDPOINT FOR TESTING ONLY** This endpoint provides simple CRUD functionality to create modules for testing the job triggering infrastructure. According to the ADR, this endpoint should eventually sync modules by parsing the migration plan (created by the init phase). The proper implementation will be added when the init phase integration is complete. TODO: Replace with proper sync logic that parses the migration plan via LLM (see ADR lines 202-213) - * Creates a new module for a project - * @param projectId - - * @param projectsProjectIdModulesPostRequest - - */ - public async projectsProjectIdModulesPost( - // @ts-ignore - request: ProjectsProjectIdModulesPost, - options?: RequestOptions, - ): Promise> { - const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - - const uriTemplate = `/projects/{projectId}/modules`; - - const uri = parser.parse(uriTemplate).expand({ - projectId: request.path.projectId, - }); - - return await this.fetchApi.fetch(`${baseUrl}${uri}`, { - headers: { - 'Content-Type': 'application/json', - ...(options?.token && { Authorization: `Bearer ${options?.token}` }), - }, - method: 'POST', - body: JSON.stringify(request.body), - }); - } - /** * Updates an existing project. * @param projectId - diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts index 1eb85ee29a..9ff1a03933 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts @@ -50,4 +50,8 @@ export interface Module { * Detailed error information if the module failed to execute */ errorDetails?: string; + /** + * Timestamp when the module was soft-deleted during a migration plan resync. Null/missing for active modules. + */ + removedAt?: Date; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts index 051ff4e724..4599c18b08 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -26,4 +26,5 @@ export type ModuleStatus = | 'running' | 'success' | 'error' - | 'cancelled'; + | 'cancelled' + | 'removed'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts index 98fd18b249..72fc721563 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts @@ -50,4 +50,8 @@ export interface ModulesStatusSummary { * Number of modules in cancelled state (last job was cancelled by the user) */ cancelled: number; + /** + * Number of soft-deleted modules (excluded from other counts) + */ + removed: number; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts deleted file mode 100644 index e94e0e872d..0000000000 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesPostRequest.model.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export interface ProjectsProjectIdModulesPostRequest { - /** - * Module name - */ - name: string; - /** - * Path to the module in the source repository - */ - sourcePath: string; -} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts index d4bccfc1fe..84c3afb68a 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdRunPostRequest.model.ts @@ -31,4 +31,8 @@ export interface ProjectsProjectIdRunPostRequest { * Optional user prompt for customizing the migration */ userPrompt?: string; + /** + * When true, runs init in refresh mode to resync the module list from an existing migration plan + */ + refresh?: boolean; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts index 6395604fe2..320cc999ef 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts @@ -36,7 +36,6 @@ export * from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; export * from '../models/ProjectsProjectIdDelete200Response.model'; export * from '../models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model'; export * from '../models/ProjectsProjectIdModulesModuleIdRunPostRequest.model'; -export * from '../models/ProjectsProjectIdModulesPostRequest.model'; export * from '../models/ProjectsProjectIdPatchRequest.model'; export * from '../models/ProjectsProjectIdRunPost200Response.model'; export * from '../models/ProjectsProjectIdRunPostRequest.model'; diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index eeb5e18f5f..09df06a4a5 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -102,7 +102,6 @@ export class DefaultApiClient { projectsProjectIdModulesModuleIdGet(request: ProjectsProjectIdModulesModuleIdGet, options?: RequestOptions): Promise>; projectsProjectIdModulesModuleIdLogGet(request: ProjectsProjectIdModulesModuleIdLogGet, options?: RequestOptions): Promise>; projectsProjectIdModulesModuleIdRunPost(request: ProjectsProjectIdModulesModuleIdRunPost, options?: RequestOptions): Promise>; - projectsProjectIdModulesPost(request: ProjectsProjectIdModulesPost, options?: RequestOptions): Promise>; projectsProjectIdPatch(request: ProjectsProjectIdPatch, options?: RequestOptions): Promise>; projectsProjectIdRunPost(request: ProjectsProjectIdRunPost, options?: RequestOptions): Promise>; rulesGet(request: RulesGet, options?: RequestOptions): Promise>; @@ -231,6 +230,7 @@ export interface Module { projectId: string; // (undocumented) publish?: Job; + removedAt?: Date; sourcePath: string; // (undocumented) status?: ModuleStatus; @@ -247,13 +247,14 @@ export interface ModulesStatusSummary { error: number; finished: number; pending: number; + removed: number; running: number; total: number; waiting: number; } // @public (undocumented) -export type ModuleStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled'; +export type ModuleStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled' | 'removed'; // @public export function normalizeRepoUrl(url: string): string; @@ -484,20 +485,6 @@ export interface ProjectsProjectIdModulesModuleIdRunPostRequest { targetRepoAuth?: GitRepoAuth; } -// @public (undocumented) -export type ProjectsProjectIdModulesPost = { - path: { - projectId: string; - }; - body: ProjectsProjectIdModulesPostRequest; -}; - -// @public (undocumented) -export interface ProjectsProjectIdModulesPostRequest { - name: string; - sourcePath: string; -} - // @public (undocumented) export type ProjectsProjectIdPatch = { path: { @@ -534,6 +521,7 @@ export type ProjectsProjectIdRunPost200ResponseStatusEnum = 'pending'; export interface ProjectsProjectIdRunPostRequest { // (undocumented) aapCredentials?: AAPCredentials; + refresh?: boolean; // (undocumented) sourceRepoAuth: GitRepoAuth; // (undocumented) diff --git a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.test.ts b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.test.ts index 7356c2e0f4..3661cecc6c 100644 --- a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.test.ts +++ b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.test.ts @@ -46,6 +46,7 @@ describe('x2a-list-modules MCP tool', () => { ); expect(x2aDatabase.listModules).toHaveBeenCalledWith({ projectId: 'proj-001', + includeRemoved: undefined, }); expect(result.output).toEqual({ projectId: 'proj-001', diff --git a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts index b48b92a705..1a5f6e7569 100644 --- a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts +++ b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts @@ -73,6 +73,8 @@ export function buildListModulesOutputSchema(z: typeof zod) { }) .describe('Latest job for this phase on the module, if any.'); + const moduleStatus = z.enum([...X2A_JOB_STATUS_VALUES, 'removed']); + const moduleItem = z .object({ id: z.string().describe('UUID of the module.'), @@ -81,7 +83,7 @@ export function buildListModulesOutputSchema(z: typeof zod) { .string() .describe('Path to the module within the source repository.'), projectId: z.string(), - status: jobPhaseStatus + status: moduleStatus .optional() .describe('Aggregate module status derived from phase jobs.'), errorDetails: z @@ -164,11 +166,17 @@ export function createListModulesAction(options: X2aActionsOptions) { projectId: z .string() .describe('UUID of the project whose modules should be listed.'), + includeRemoved: z + .boolean() + .optional() + .describe( + 'When true, include modules that were soft-deleted during a migration plan resync. Defaults to false.', + ), }), output: z => buildListModulesOutputSchema(z), }, action: async ({ input, credentials }) => { - const { projectId } = input; + const { projectId, includeRemoved } = input; logger.info(`MCP tool x2a-list-modules invoked for project ${projectId}`); const ctx = await resolveCredentialsContext({ @@ -193,7 +201,10 @@ export function createListModulesAction(options: X2aActionsOptions) { ); } - const modules = await x2aDatabase.listModules({ projectId }); + const modules = await x2aDatabase.listModules({ + projectId, + includeRemoved, + }); await listModulesWithReconciledStatuses(modules, { kubeService, x2aDatabase, diff --git a/workspaces/x2a/plugins/x2a-node/report.api.md b/workspaces/x2a/plugins/x2a-node/report.api.md index 7345cddb55..08b8f73475 100644 --- a/workspaces/x2a/plugins/x2a-node/report.api.md +++ b/workspaces/x2a/plugins/x2a-node/report.api.md @@ -135,6 +135,8 @@ export interface JobCreateParams { // (undocumented) projectName: string; // (undocumented) + refresh?: boolean; + // (undocumented) sourceRepo: GitRepo; // (undocumented) sourceTechnology?: SourceTechnology; @@ -362,6 +364,7 @@ export interface X2ADatabaseServiceApi { // (undocumented) listModules(args: { projectId: string; + includeRemoved?: boolean; }): Promise; // (undocumented) listProjects(query: ProjectsGet['query'], options: { @@ -375,6 +378,14 @@ export interface X2ADatabaseServiceApi { // (undocumented) listRules(): Promise; // (undocumented) + restoreModule(args: { + id: string; + }): Promise; + // (undocumented) + softDeleteModule(args: { + id: string; + }): Promise; + // (undocumented) updateJob(update: { id: string; log?: string | null; @@ -387,6 +398,12 @@ export interface X2ADatabaseServiceApi { commitId?: string; }): Promise; // (undocumented) + updateModule(args: { + id: string; + sourcePath?: string; + technology?: Module['technology']; + }): Promise; + // (undocumented) updateProject(args: { projectId: string; }, input: { diff --git a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts index 341eda5e81..206677391b 100644 --- a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts @@ -112,10 +112,23 @@ export interface X2ADatabaseServiceApi { skipEnrichment?: boolean; }): Promise; - listModules(args: { projectId: string }): Promise; + listModules(args: { + projectId: string; + includeRemoved?: boolean; + }): Promise; deleteModule(args: { id: string }): Promise; + softDeleteModule(args: { id: string }): Promise; + + restoreModule(args: { id: string }): Promise; + + updateModule(args: { + id: string; + sourcePath?: string; + technology?: Module['technology']; + }): Promise; + createJob(job: CreateJobInput): Promise; getJob(args: { id: string }): Promise; diff --git a/workspaces/x2a/plugins/x2a-node/src/services/types.ts b/workspaces/x2a/plugins/x2a-node/src/services/types.ts index 7fb9725e57..05f2b1ffed 100644 --- a/workspaces/x2a/plugins/x2a-node/src/services/types.ts +++ b/workspaces/x2a/plugins/x2a-node/src/services/types.ts @@ -119,4 +119,5 @@ export interface JobCreateParams { targetRepo: GitRepo; aapCredentials?: AAPCredentials; acceptedRules?: RuleSnapshot[]; + refresh?: boolean; } diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index eba66492c0..f6813eb26f 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -99,6 +99,7 @@ readonly "editProjectDialog.ownerChangeConfirm": string; readonly "editProjectDialog.nameRequired": string; readonly "editProjectDialog.ownerFormatHint": string; readonly "projectModulesCard.title": string; +readonly "projectModulesCard.spinner": string; readonly "projectModulesCard.published": string; readonly "projectModulesCard.noModules": string; readonly "projectModulesCard.toReview": string; @@ -204,6 +205,7 @@ readonly "module.summary.total": string; readonly "module.summary.error": string; readonly "module.summary.pending": string; readonly "module.summary.cancelled": string; +readonly "module.summary.removed": string; readonly "module.summary.finished": string; readonly "module.summary.waiting": string; readonly "module.summary.toReview_one": string; @@ -222,6 +224,7 @@ readonly "module.statuses.error": string; readonly "module.statuses.pending": string; readonly "module.statuses.success": string; readonly "module.statuses.cancelled": string; +readonly "module.statuses.removed": string; readonly "module.notStarted": string; readonly "module.actions.runNextPhase": string; readonly "module.actions.cancelPhase": string; @@ -230,6 +233,14 @@ readonly "module.actions.runNextPhaseError": string; readonly "module.currentPhase": string; readonly "module.lastUpdate": string; readonly "module.sourcePath": string; +readonly "resyncMigrationPlan.action": string; +readonly "resyncMigrationPlan.running": string; +readonly "resyncMigrationPlan.error": string; +readonly "resyncMigrationPlan.confirm.title": string; +readonly "resyncMigrationPlan.confirm.message": string; +readonly "resyncMigrationPlan.confirm.confirmButton": string; +readonly "resyncMigrationPlan.confirm.warning": string; +readonly "resyncMigrationPlan.errorStart": string; readonly "artifact.types.migration_plan": string; readonly "artifact.types.module_migration_plan": string; readonly "artifact.types.migrated_sources": string; diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.test.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.test.tsx index fe9aebbd07..9fcf17e2fe 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.test.tsx @@ -115,4 +115,18 @@ describe('ModuleStatusCell', () => { expect(screen.getByText('Cancelled')).toBeInTheDocument(); expect(screen.queryByText('review')).not.toBeInTheDocument(); }); + + it('renders removed status when removedAt is set, regardless of underlying status', () => { + renderWithTheme( + , + ); + expect(screen.getByText('Removed')).toBeInTheDocument(); + expect(screen.queryByText('Success')).not.toBeInTheDocument(); + }); }); diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx index bdd27ff1b3..d75eb7e162 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx @@ -69,6 +69,8 @@ const StatusWithText = ({ return {children}; case 'cancelled': return {children}; + case 'removed': + return {children}; default: return ( @@ -86,7 +88,9 @@ export const ModuleStatusCell = ({ module }: { module?: Module }) => { const { t } = useTranslation(); const styles = useStyles(); - const status = module?.status; + const status: ModuleStatus | undefined = module?.removedAt + ? 'removed' + : module?.status; let chip; if (status === 'success') { diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.test.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.test.tsx index d612c7522d..9deec0de88 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.test.tsx @@ -257,6 +257,7 @@ describe('ProjectTable', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }; const renderTable = (projects: Project[]) => { @@ -458,6 +459,7 @@ describe('ProjectTable', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }; const openGlobalDialog = (projects: Project[]) => { diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/InitPhaseCard.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/InitPhaseCard.tsx index 1f36f1806b..127550c216 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/InitPhaseCard.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/InitPhaseCard.tsx @@ -28,7 +28,6 @@ export const InitPhaseCard = ({ project }: { project: Project }) => { phase={project.initJob} phaseName="init" projectId={project.id} - // TODO:Add onRunPhase to resync the migration plan /> ); diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectActions.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectActions.tsx index 1c06653229..b49ea33cfe 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectActions.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectActions.tsx @@ -17,6 +17,7 @@ import MoreVert from '@material-ui/icons/MoreVert'; import DeleteIcon from '@material-ui/icons/Delete'; import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay'; import ReplayIcon from '@material-ui/icons/Replay'; +import SyncIcon from '@material-ui/icons/Sync'; import { Divider, ListItemIcon, @@ -35,8 +36,10 @@ export type ProjectActionsProps = { handleDeleteClick: () => void; handleRunAllClick: () => void; handleRetriggerInitClick: () => void; + handleResyncClick: () => void; canRunAll: boolean; canRetriggerInit: boolean; + canResync: boolean; canDeleteProject: boolean; }; @@ -48,8 +51,10 @@ export const ProjectActions = ({ handleDeleteClick, handleRunAllClick, handleRetriggerInitClick, + handleResyncClick, canRunAll, canRetriggerInit, + canResync, canDeleteProject, }: ProjectActionsProps) => { const { t } = useTranslation(); @@ -92,6 +97,13 @@ export const ProjectActions = ({ {t('table.actions.retriggerInit')} + + + + + {t('resyncMigrationPlan.action' as any, {})} + + diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectModulesCard.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectModulesCard.tsx index b06392bb0e..fd0f188e49 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectModulesCard.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectModulesCard.tsx @@ -16,7 +16,14 @@ import { Fragment } from 'react'; import { InfoCard, Link } from '@backstage/core-components'; import { Module } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; -import { Divider, Grid, Typography } from '@material-ui/core'; +import { + Box, + CircularProgress, + Divider, + Grid, + Tooltip, + Typography, +} from '@material-ui/core'; import { useTranslation } from '../../hooks/useTranslation'; import { ModuleStatusCell } from '../ModuleStatusCell'; import { CurrentPhaseCell } from '../CurrentPhaseCell'; @@ -24,19 +31,41 @@ import { getLastPhaseReached } from '../tools'; import { useRouteRef } from '@backstage/core-plugin-api'; import { moduleRouteRef } from '../../routes'; -export const ProjectModulesCard = ({ modules }: { modules: Module[] }) => { +export const ProjectModulesCard = ({ + modules, + resyncing, +}: { + modules: Module[]; + resyncing?: boolean; +}) => { const { t } = useTranslation(); const modulePath = useRouteRef(moduleRouteRef); - return ( - { + const aRemoved = !!a.removedAt; + const bRemoved = !!b.removedAt; + if (aRemoved !== bRemoved) return aRemoved ? 1 : -1; + return a.name.localeCompare(b.name); + }); + const activeCount = modules.filter(m => !m.removedAt).length; + + const title = ( + + {t('projectModulesCard.title' as any, { + count: activeCount.toString(), })} - variant="gridItem" - > + {resyncing && ( + + + + )} + + ); + + return ( + - {modules.length === 0 && ( + {sortedModules.length === 0 && ( {t('projectModulesCard.noModules')} @@ -44,7 +73,7 @@ export const ProjectModulesCard = ({ modules }: { modules: Module[] }) => { )} - {modules.map((module, index) => { + {sortedModules.map((module, index) => { const lastJob = getLastPhaseReached(module); return ( @@ -64,7 +93,7 @@ export const ProjectModulesCard = ({ modules }: { modules: Module[] }) => { - {index < modules.length - 1 && ( + {index < sortedModules.length - 1 && ( diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectPage.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectPage.tsx index bdef9b8d20..a0c7ababb0 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectPage.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectPage/ProjectPage.tsx @@ -25,6 +25,7 @@ import { } from '@backstage/core-components'; import { Box, Grid } from '@material-ui/core'; import { + JobStatus, Module, Project, RUN_INIT_DEEP_LINK_HASH, @@ -47,12 +48,14 @@ import { RetriggerInitConfirmDialog, RetriggerInitConfirmDialogCopyVariant, } from '../RetriggerInitConfirmDialog'; +import { ResyncMigrationPlanDialog } from '../ResyncMigrationPlanDialog'; import { ProjectActions, ProjectActionsProps } from './ProjectActions'; import { extractResponseError, isHttpSuccessResponse, canRunNextPhase, isEligibleForRetriggerInit, + isEligibleForResync, } from '../tools'; export const ProjectPage = () => { @@ -62,7 +65,7 @@ export const ProjectPage = () => { const { projectId } = useRouteRefParams(projectRouteRef); const rootPath = useRouteRef(rootRouteRef); const clientService = useClientService(); - const { runAllForProject, retriggerInit } = useBulkRun(); + const { runAllForProject, retriggerInit, resyncMigrationPlan } = useBulkRun(); const { canWriteProject } = useProjectWriteAccess(); const runInitDeepLinkHandledRef = useRef(false); const runNextDeepLinkHandledRef = useRef(false); @@ -76,6 +79,9 @@ export const ProjectPage = () => { const [isRetriggeringInit, setIsRetriggeringInit] = useState(false); const [bulkRunModalOpen, setBulkRunModalOpen] = useState(false); const [isBulkRunning, setIsBulkRunning] = useState(false); + const [resyncModalOpen, setResyncModalOpen] = useState(false); + const [isResyncing, setIsResyncing] = useState(false); + const [resyncTriggered, setResyncTriggered] = useState(false); const menuOpen = Boolean(menuAnchorEl); const handleMenuOpen: ProjectActionsProps['handleMenuOpen'] = useCallback( @@ -172,6 +178,19 @@ export const ProjectPage = () => { runNextDeepLinkHandledRef.current = false; }, [projectId]); + // Init job is currently active (pending or running) + const initJobRunning = + !!project?.initJob?.status && + JobStatus.from(project.initJob.status).isActive(); + + // Once the server shows the init job running, the server is the source of truth + // and the local flag is no longer needed. + useEffect(() => { + if (resyncTriggered && initJobRunning) { + setResyncTriggered(false); + } + }, [resyncTriggered, initJobRunning]); + const openBulkRunDialog = useCallback(() => { setError(null); handleMenuClose(); @@ -302,6 +321,42 @@ export const ProjectPage = () => { } }, [project, modules, runAllForProject, forceRefresh, t]); + const handleResyncClick = useCallback(() => { + setError(null); + handleMenuClose(); + setResyncModalOpen(true); + }, [handleMenuClose]); + + const handleResyncModalClose = useCallback(() => { + if (!isResyncing) { + setResyncModalOpen(false); + setError(null); + } + }, [isResyncing]); + + const handleResyncConfirm = useCallback(async () => { + if (!project) return; + setError(null); + setIsResyncing(true); + + try { + await resyncMigrationPlan(project); + setResyncModalOpen(false); + setResyncTriggered(true); + forceRefresh(); + } catch (e) { + setResyncModalOpen(false); + const msg = e instanceof Error ? e.message : String(e); + setError( + new Error( + `${t('resyncMigrationPlan.error' as any, { name: project.name })}: ${msg}`, + ), + ); + } finally { + setIsResyncing(false); + } + }, [project, resyncMigrationPlan, forceRefresh, t]); + if (loadError) { return ( @@ -316,6 +371,16 @@ export const ProjectPage = () => { const projectWritePermitted = !!(project && canWriteProject(project)); const hasEligibleModules = !!project && !!modules && modules.some(m => canRunNextPhase(m, project)); + const canResync = + projectWritePermitted && !!project && isEligibleForResync(project); + + // Spinner shows when: + // - init job is active on a project that was already initialized (not a first-time init) OR + // - we just triggered a resync and polling hasn't caught up yet (local flag) + const isResyncRunning = + (initJobRunning && !!project && !isEligibleForRetriggerInit(project)) || + resyncTriggered; + return (
@@ -328,10 +393,12 @@ export const ProjectPage = () => { handleDeleteClick={handleDeleteClick} handleRunAllClick={handleRunAllClick} handleRetriggerInitClick={handleRetriggerInitClick} + handleResyncClick={handleResyncClick} canRunAll={projectWritePermitted && hasEligibleModules} canRetriggerInit={ projectWritePermitted && isEligibleForRetriggerInit(project) } + canResync={canResync} canDeleteProject={projectWritePermitted} /> )} @@ -366,6 +433,14 @@ export const ProjectPage = () => { onClose={handleBulkRunModalClose} /> + + @@ -381,7 +456,10 @@ export const ProjectPage = () => { - + diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectStatusCell.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectStatusCell.tsx index 1e28e49db7..2fadb2dfc0 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectStatusCell.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectStatusCell.tsx @@ -144,6 +144,12 @@ export const ProjectStatusCell = ({ label={t('module.summary.cancelled')} value={modulesSummary.cancelled} /> + {modulesSummary.removed > 0 && ( + + )} ); } diff --git a/workspaces/x2a/plugins/x2a/src/components/ResyncMigrationPlanDialog.tsx b/workspaces/x2a/plugins/x2a/src/components/ResyncMigrationPlanDialog.tsx new file mode 100644 index 0000000000..2fa46df468 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/ResyncMigrationPlanDialog.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CircularProgress, + Typography, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core'; +import { useTranslation } from '../hooks/useTranslation'; + +export type ResyncMigrationPlanDialogProps = { + open: boolean; + projectName: string; + isRunning: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +export const ResyncMigrationPlanDialog = ({ + open, + projectName, + isRunning, + onConfirm, + onClose, +}: ResyncMigrationPlanDialogProps) => { + const { t } = useTranslation(); + + return ( + + + {t('resyncMigrationPlan.confirm.title' as any, { name: projectName })} + + + + {t('resyncMigrationPlan.confirm.message' as any, {})} + + + {t('resyncMigrationPlan.confirm.warning' as any, {})} + + + + + + + + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/areEligibleModulesToRun.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/areEligibleModulesToRun.test.ts index db97456ddf..0a7454b06b 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/areEligibleModulesToRun.test.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/areEligibleModulesToRun.test.ts @@ -49,6 +49,7 @@ const zeroSummary: ModulesStatusSummary = { running: 0, error: 0, cancelled: 0, + removed: 0, }; describe('areEligibleModulesToRun', () => { @@ -178,6 +179,7 @@ describe('areEligibleModulesToRun', () => { running: 2, error: 2, cancelled: 0, + removed: 0, }, }, }; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.test.ts new file mode 100644 index 0000000000..766929d45f --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Artifact, + Module, + Project, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import { canRunNextPhase } from './canRunNextPhase'; + +const migrationPlanArtifact: Artifact = { + id: 'artifact-1', + type: 'migration_plan', + value: 'https://repo.example.com/plan.md', +}; + +const baseProject: Project = { + id: '123', + name: 'Test Project', + sourceRepoUrl: 'https://example.com/source', + targetRepoUrl: 'https://example.com/target', + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + createdAt: new Date('2024-01-01'), + ownedBy: 'user:default/tester', + migrationPlan: migrationPlanArtifact, +}; + +const baseModule: Module = { + id: 'mod-1', + name: 'test-module', + sourcePath: '/cookbooks/test', + projectId: '123', +}; + +describe('canRunNextPhase', () => { + it('returns false when module has removedAt set', () => { + const removed: Module = { + ...baseModule, + removedAt: new Date('2026-01-15T00:00:00Z'), + }; + expect(canRunNextPhase(removed, baseProject)).toBe(false); + }); + + it('returns false when module has removedAt set even with a valid next phase', () => { + const removed: Module = { + ...baseModule, + removedAt: new Date('2026-01-15T00:00:00Z'), + analyze: { + id: 'job-analyze', + status: 'success', + phase: 'analyze', + projectId: '123', + moduleId: 'mod-1', + startedAt: new Date(), + k8sJobName: 'k8s-analyze-1', + }, + }; + expect(canRunNextPhase(removed, baseProject)).toBe(false); + }); + + it('returns true for an active module that can proceed to the next phase', () => { + expect(canRunNextPhase(baseModule, baseProject)).toBe(true); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.ts b/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.ts index 8feacfd453..7b2a3927ca 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/canRunNextPhase.ts @@ -22,6 +22,10 @@ import { getNextPhase } from './getNextPhase'; import { hasPhasePrerequisites } from './hasPhasePrerequisites'; export const canRunNextPhase = (module: Module, project: Project): boolean => { + if (module.removedAt) { + return false; + } + const nextPhase = getNextPhase(module); if (!nextPhase) { diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/index.ts b/workspaces/x2a/plugins/x2a/src/components/tools/index.ts index ace43a4077..ec93315779 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/index.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/index.ts @@ -27,3 +27,4 @@ export * from './downloadLogFile'; export * from './hasPhasePrerequisites'; export * from './areEligibleModulesToRun'; export * from './isEligibleForRetriggerInit'; +export * from './isEligibleForResync'; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.test.ts new file mode 100644 index 0000000000..8d9c82de38 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Artifact, + Job, + JobStatusEnum, + Project, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import { isEligibleForResync } from './isEligibleForResync'; + +const migrationPlanArtifact: Artifact = { + id: 'artifact-1', + type: 'migration_plan', + value: 'https://repo.example.com/plan.md', +}; + +const baseProject: Project = { + id: '123', + name: 'Test Project', + sourceRepoUrl: 'https://example.com/source', + targetRepoUrl: 'https://example.com/target', + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + createdAt: new Date('2024-01-01'), + ownedBy: 'user:default/tester', +}; + +const makeInitJob = (status: JobStatusEnum): Job => + ({ + id: 'job-1', + status, + }) as Job; + +describe('isEligibleForResync', () => { + it('returns false when project has no migration plan', () => { + expect(isEligibleForResync(baseProject)).toBe(false); + }); + + it('returns false when migrationPlan is undefined and init job succeeded', () => { + const project: Project = { + ...baseProject, + initJob: makeInitJob('success'), + }; + expect(isEligibleForResync(project)).toBe(false); + }); + + it('returns true when migration plan exists and no init job is running', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + }; + expect(isEligibleForResync(project)).toBe(true); + }); + + it('returns true when migration plan exists and init job completed with success', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + initJob: makeInitJob('success'), + }; + expect(isEligibleForResync(project)).toBe(true); + }); + + it('returns true when migration plan exists and init job has error status', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + initJob: makeInitJob('error'), + }; + expect(isEligibleForResync(project)).toBe(true); + }); + + it('returns true when migration plan exists and init job has cancelled status', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + initJob: makeInitJob('cancelled'), + }; + expect(isEligibleForResync(project)).toBe(true); + }); + + it('returns false when migration plan exists but init job is running', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + initJob: makeInitJob('running'), + }; + expect(isEligibleForResync(project)).toBe(false); + }); + + it('returns false when migration plan exists but init job is pending', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + initJob: makeInitJob('pending'), + }; + expect(isEligibleForResync(project)).toBe(false); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.ts b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.ts new file mode 100644 index 0000000000..de4cdfe7bb --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForResync.ts @@ -0,0 +1,36 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + JobStatus, + Project, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +/** + * A project is eligible for migration plan resync when: + * - A migration plan already exists (init phase succeeded at least once) + * - No init job is currently running + * + * This is distinct from `isEligibleForRetriggerInit` which covers the case + * where the first init failed and needs to be re-run from scratch. + */ +export const isEligibleForResync = (project: Project): boolean => { + const hasMigrationPlan = !!project.migrationPlan; + const initJobStatus = project.initJob?.status; + const initRunning = + !!initJobStatus && JobStatus.from(initJobStatus).isActive(); + + return hasMigrationPlan && !initRunning; +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.test.ts index f205f8e3b3..977e7594e3 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.test.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.test.ts @@ -15,6 +15,7 @@ */ import { + Artifact, Job, JobStatusEnum, Project, @@ -22,6 +23,12 @@ import { import { isEligibleForRetriggerInit } from './isEligibleForRetriggerInit'; +const migrationPlanArtifact: Artifact = { + id: 'artifact-1', + type: 'migration_plan', + value: 'https://repo.example.com/plan.md', +}; + const baseProject: Project = { id: '123', name: 'Test Project', @@ -65,6 +72,7 @@ describe('isEligibleForRetriggerInit', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }, }, }; @@ -124,6 +132,28 @@ describe('isEligibleForRetriggerInit', () => { running: 1, error: 0, cancelled: 0, + removed: 0, + }, + }, + }; + expect(isEligibleForRetriggerInit(project)).toBe(false); + }); + + it('returns false when migration plan exists but no active modules', () => { + const project: Project = { + ...baseProject, + migrationPlan: migrationPlanArtifact, + status: { + state: 'initialized', + modulesSummary: { + total: 0, + finished: 0, + waiting: 0, + pending: 0, + running: 0, + error: 0, + cancelled: 0, + removed: 2, }, }, }; @@ -144,6 +174,7 @@ describe('isEligibleForRetriggerInit', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }, }, }; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.ts b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.ts index 229ab349b6..a36d337d90 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/isEligibleForRetriggerInit.ts @@ -28,5 +28,5 @@ export const isEligibleForRetriggerInit = (project: Project): boolean => { !!initJobStatus && JobStatus.from(initJobStatus).isActive(); const hasModules = !!project.status?.modulesSummary && project.status.modulesSummary.total > 0; - return !hasModules && !initRunning; + return !hasModules && !initRunning && !project.migrationPlan; }; diff --git a/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.test.ts b/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.test.ts index 5055b90506..090266dcbf 100644 --- a/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.test.ts +++ b/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.test.ts @@ -101,6 +101,7 @@ const withModulesStatus: Pick = { running: 0, error: 0, cancelled: 0, + removed: 0, }, }, }; @@ -752,6 +753,7 @@ describe('useBulkRun', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }, }, }); @@ -794,6 +796,7 @@ describe('useBulkRun', () => { running: 0, error: 0, cancelled: 0, + removed: 0, }, }, }); diff --git a/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.ts b/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.ts index 17eba1b689..e414e880b0 100644 --- a/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.ts +++ b/workspaces/x2a/plugins/x2a/src/hooks/useBulkRun.ts @@ -251,5 +251,35 @@ export const useBulkRun = () => { [fetchAllProjects, processProject], ); - return { runAllForProject, runAllGlobal, retriggerInit }; + const resyncMigrationPlan = useCallback( + async (project: Project): Promise => { + const { sourceToken, targetToken } = await getProjectAuthTokens(project); + + const response = await clientService.projectsProjectIdRunPost({ + path: { projectId: project.id }, + body: { + sourceRepoAuth: { token: sourceToken }, + targetRepoAuth: { token: targetToken }, + refresh: true, + }, + }); + + if (!isHttpSuccessResponse(response)) { + const message = await extractResponseError( + response, + t('resyncMigrationPlan.errorStart' as any, {}), + ); + throw new Error(message); + } + + const responseData = await response.json(); + if (!responseData.jobId) { + throw new Error('No jobId returned for migration plan resync'); + } + return responseData.jobId; + }, + [clientService, getProjectAuthTokens, t], + ); + + return { runAllForProject, runAllGlobal, retriggerInit, resyncMigrationPlan }; }; diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index b217ca3fb4..13b1b49b3c 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -69,6 +69,8 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'projectModulesCard.noModules': 'Noch keine Module gefunden...', 'projectModulesCard.toReview': 'überprüfen', 'projectModulesCard.published': 'veröffentlicht', + 'projectModulesCard.spinner': + 'Erkennungsphase läuft und Modulliste wird aus dem Migrationsplan aktualisiert…', 'projectPage.title': 'Projekt', 'projectPage.actionsTooltip': 'Klicken Sie, um das Menü für Projektaktionen zu öffnen', @@ -112,6 +114,7 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.summary.running': 'Läuft', 'module.summary.error': 'Fehler', 'module.summary.cancelled': 'Abgebrochen', + 'module.summary.removed': 'Entfernt', 'module.summary.toReview_one': '{{count}} Modul mit zu überprüfenden Artefakten', 'module.summary.toReview_other': @@ -137,6 +140,7 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.statuses.success': 'Erfolg', 'module.statuses.error': 'Fehler', 'module.statuses.cancelled': 'Abgebrochen', + 'module.statuses.removed': 'Entfernt', 'artifact.types.migrated_sources': 'Migrierte Quellen', 'artifact.types.project_metadata': 'Projektmetadaten', 'artifact.types.ansible_project': 'AAP-Projekt', @@ -264,6 +268,20 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'Fehler beim erneuten Auslösen der Init-Phase für Projekt „{{name}}"', 'retriggerInit.errorStart': 'Fehler beim Starten der Projektinitialisierung', + 'resyncMigrationPlan.action': 'Migrationsplan neu synchronisieren', + 'resyncMigrationPlan.confirm.title': + 'Migrationsplan für „{{name}}" neu synchronisieren?', + 'resyncMigrationPlan.confirm.message': + 'Der Migrationsplan wird aus dem Ziel-Repository neu eingelesen und die Modulliste entsprechend aktualisiert. Neue Module werden hinzugefügt und Module, die nicht mehr im Plan enthalten sind, werden als entfernt markiert. Wenn Sie das Dokument ändern, etwa ein Modul entfernen, achten Sie darauf, dass das Dokument schlüssig bleibt.', + 'resyncMigrationPlan.confirm.warning': + 'Als entfernt markierte Module behalten ihre Job-Historie, sind aber nicht mehr für neue Phasenläufe berechtigt. Diese Aktion kann für entfernte Module nicht rückgängig gemacht werden, es sei denn, sie werden dem Migrationsplan erneut hinzugefügt.', + 'resyncMigrationPlan.confirm.confirmButton': 'Neu synchronisieren', + 'resyncMigrationPlan.running': + 'Modulliste wird vom Migrationsplan neu synchronisiert…', + 'resyncMigrationPlan.error': + 'Fehler bei der Neusynchronisierung des Migrationsplans für Projekt „{{name}}"', + 'resyncMigrationPlan.errorStart': + 'Fehler beim Starten der Migrationsplan-Neusynchronisierung', 'scaffolder.rulesAcceptance.loadingRules': 'Regeln werden geladen...', 'scaffolder.rulesAcceptance.noRulesConfigured': 'Keine Regeln konfiguriert.', diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index 9850ccb332..2145a9baa1 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -69,6 +69,8 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'projectModulesCard.noModules': 'Aún no se encontraron módulos...', 'projectModulesCard.toReview': 'revisar', 'projectModulesCard.published': 'publicado', + 'projectModulesCard.spinner': + 'Ejecutando la fase de descubrimiento y actualizando la lista de módulos desde el plan de migración…', 'projectPage.title': 'Proyecto', 'projectPage.actionsTooltip': 'Haga clic para abrir el menú para las acciones del proyecto', @@ -113,6 +115,7 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.summary.running': 'En ejecución', 'module.summary.error': 'Error', 'module.summary.cancelled': 'Cancelado', + 'module.summary.removed': 'Eliminado', 'module.summary.toReview_one': '{{count}} módulo con artefactos para revisar', 'module.summary.toReview_other': @@ -138,6 +141,7 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.statuses.success': 'Éxito', 'module.statuses.error': 'Error', 'module.statuses.cancelled': 'Cancelado', + 'module.statuses.removed': 'Eliminado', 'artifact.types.migrated_sources': 'Fuentes migradas', 'artifact.types.project_metadata': 'Metadatos del proyecto', 'artifact.types.ansible_project': 'Proyecto AAP', @@ -270,6 +274,20 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'Error al reiniciar la fase de inicio del proyecto "{{name}}"', 'retriggerInit.errorStart': 'Error al iniciar la inicialización del proyecto', + 'resyncMigrationPlan.action': 'Resincronizar plan de migración', + 'resyncMigrationPlan.confirm.title': + '¿Resincronizar el plan de migración para "{{name}}"?', + 'resyncMigrationPlan.confirm.message': + 'Esta operación releerá el plan de migración del repositorio de destino y actualizará la lista de módulos en consecuencia. Se añadirán los módulos nuevos y los que ya no estén en el plan se marcarán como eliminados. Si modifica el documento, por ejemplo eliminando un módulo, asegúrese de que el documento siga siendo coherente.', + 'resyncMigrationPlan.confirm.warning': + 'Los módulos marcados como eliminados conservarán su historial de trabajos pero ya no serán elegibles para nuevas ejecuciones de fase. Esta acción no se puede deshacer para los módulos eliminados a menos que se vuelvan a añadir al plan de migración.', + 'resyncMigrationPlan.confirm.confirmButton': 'Resincronizar', + 'resyncMigrationPlan.running': + 'Resincronizando la lista de módulos desde el plan de migración…', + 'resyncMigrationPlan.error': + 'Error al resincronizar el plan de migración del proyecto "{{name}}"', + 'resyncMigrationPlan.errorStart': + 'Error al iniciar la resincronización del plan de migración', 'scaffolder.rulesAcceptance.loadingRules': 'Cargando reglas...', 'scaffolder.rulesAcceptance.noRulesConfigured': 'No hay reglas configuradas.', diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index 91dfbf8507..e882fc288b 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -70,6 +70,8 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'projectModulesCard.noModules': 'Aucun module trouvé pour le moment...', 'projectModulesCard.toReview': 'réviser', 'projectModulesCard.published': 'publié', + 'projectModulesCard.spinner': + 'Phase de découverte en cours et mise à jour de la liste des modules depuis le plan de migration…', 'projectPage.title': 'Projet', 'projectPage.actionsTooltip': 'Cliquez pour ouvrir le menu pour les actions du projet', @@ -114,6 +116,7 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.summary.running': 'En cours', 'module.summary.error': 'Erreur', 'module.summary.cancelled': 'Annulé', + 'module.summary.removed': 'Supprimé', 'module.summary.toReview_one': '{{count}} module avec des artefacts à réviser', 'module.summary.toReview_other': @@ -139,6 +142,7 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.statuses.success': 'Succès', 'module.statuses.error': 'Erreur', 'module.statuses.cancelled': 'Annulé', + 'module.statuses.removed': 'Supprimé', 'artifact.types.migrated_sources': 'Sources migrées', 'artifact.types.project_metadata': 'Métadonnées du projet', 'artifact.types.ansible_project': 'Projet AAP', @@ -271,6 +275,20 @@ const x2aPluginTranslationFr = createTranslationMessages({ "Erreur lors de la relance de la phase d'initialisation du projet « {{name}} »", 'retriggerInit.errorStart': "Erreur lors du démarrage de l'initialisation du projet", + 'resyncMigrationPlan.action': 'Resynchroniser le plan de migration', + 'resyncMigrationPlan.confirm.title': + 'Resynchroniser le plan de migration pour « {{name}} » ?', + 'resyncMigrationPlan.confirm.message': + "Cette opération relira le plan de migration depuis le dépôt cible et mettra à jour la liste des modules en conséquence. Les nouveaux modules seront ajoutés et ceux qui ne figurent plus dans le plan seront marqués comme supprimés. Si vous modifiez le document, par exemple en supprimant un module, assurez-vous qu'il reste cohérent.", + 'resyncMigrationPlan.confirm.warning': + "Les modules marqués comme supprimés conserveront leur historique de jobs mais ne seront plus éligibles à de nouvelles exécutions de phase. Cette action ne peut pas être annulée pour les modules supprimés sauf s'ils sont rajoutés au plan de migration.", + 'resyncMigrationPlan.confirm.confirmButton': 'Resynchroniser', + 'resyncMigrationPlan.running': + 'Resynchronisation de la liste des modules depuis le plan de migration…', + 'resyncMigrationPlan.error': + 'Erreur lors de la resynchronisation du plan de migration pour le projet « {{name}} »', + 'resyncMigrationPlan.errorStart': + 'Erreur lors du démarrage de la resynchronisation du plan de migration', 'scaffolder.rulesAcceptance.loadingRules': 'Chargement des règles...', 'scaffolder.rulesAcceptance.noRulesConfigured': 'Aucune règle configurée.', 'scaffolder.rulesAcceptance.required': 'obligatoire', diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index f855270952..1247eff66e 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -70,6 +70,8 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'projectModulesCard.noModules': 'Nessun modulo trovato finora...', 'projectModulesCard.toReview': 'rivedere', 'projectModulesCard.published': 'pubblicato', + 'projectModulesCard.spinner': + 'Fase di scoperta in esecuzione e aggiornamento dell’elenco moduli dal piano di migrazione…', 'projectPage.title': 'Progetto', 'projectPage.actionsTooltip': 'Clicca per aprire il menu per le azioni del progetto', @@ -113,6 +115,7 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.summary.running': 'In esecuzione', 'module.summary.error': 'Errore', 'module.summary.cancelled': 'Annullato', + 'module.summary.removed': 'Rimosso', 'module.summary.toReview_one': '{{count}} modulo con artefatti da rivedere', 'module.summary.toReview_other': '{{count}} moduli con artefatti da rivedere', @@ -137,6 +140,7 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.statuses.success': 'Successo', 'module.statuses.error': 'Errore', 'module.statuses.cancelled': 'Annullato', + 'module.statuses.removed': 'Rimosso', 'artifact.types.migrated_sources': 'Sorgenti migrate', 'artifact.types.project_metadata': 'Metadati del progetto', 'artifact.types.ansible_project': 'Progetto AAP', @@ -271,6 +275,20 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'Errore nel riavvio della fase di inizializzazione del progetto "{{name}}"', 'retriggerInit.errorStart': "Errore nell'avvio dell'inizializzazione del progetto", + 'resyncMigrationPlan.action': 'Risincronizza il piano di migrazione', + 'resyncMigrationPlan.confirm.title': + 'Risincronizzare il piano di migrazione per "{{name}}"?', + 'resyncMigrationPlan.confirm.message': + 'Questa operazione rileggerà il piano di migrazione dal repository di destinazione e aggiornerà la lista dei moduli di conseguenza. I nuovi moduli verranno aggiunti e quelli non più presenti nel piano verranno contrassegnati come rimossi. Se apporti modifiche al documento, ad esempio rimuovendo un modulo, assicurati che il documento resti coerente.', + 'resyncMigrationPlan.confirm.warning': + 'I moduli contrassegnati come rimossi manterranno la cronologia dei job ma non saranno più idonei per nuove esecuzioni di fase. Questa azione non può essere annullata per i moduli rimossi a meno che non vengano nuovamente aggiunti al piano di migrazione.', + 'resyncMigrationPlan.confirm.confirmButton': 'Risincronizza', + 'resyncMigrationPlan.running': + 'Risincronizzazione della lista moduli dal piano di migrazione…', + 'resyncMigrationPlan.error': + 'Errore nella risincronizzazione del piano di migrazione per il progetto "{{name}}"', + 'resyncMigrationPlan.errorStart': + "Errore nell'avvio della risincronizzazione del piano di migrazione", 'scaffolder.rulesAcceptance.loadingRules': 'Caricamento regole...', 'scaffolder.rulesAcceptance.noRulesConfigured': 'Nessuna regola configurata.', diff --git a/workspaces/x2a/plugins/x2a/src/translations/ref.ts b/workspaces/x2a/plugins/x2a/src/translations/ref.ts index 0f3775dd54..1edbd9869b 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/ref.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/ref.ts @@ -35,7 +35,7 @@ export const x2aPluginMessages = { }, projectPage: { title: 'Project', - deleteProject: 'Delete', + deleteProject: 'Delete this project', actionsTooltip: 'Click to open the menu for project actions', deleteError: 'Failed to delete project', deleteConfirm: { @@ -78,6 +78,8 @@ export const x2aPluginMessages = { noModules: 'No modules found yet...', toReview: 'review', published: 'published', + spinner: + 'Running discovery phase and updating the module list from the migration plan…', }, initPhaseCard: { title: 'Discovery Phase', @@ -198,7 +200,7 @@ export const x2aPluginMessages = { bulkRun: { projectAction: 'Run all modules', globalAction: 'Run all', - projectPageAction: 'Run all', + projectPageAction: 'Run all modules', projectConfirm: { title: 'Run all modules in "{{name}}" project?', message: @@ -266,6 +268,7 @@ export const x2aPluginMessages = { running: 'Running', error: 'Error', cancelled: 'Cancelled', + removed: 'Removed', toReview_one: '{{count}} module with artifacts to review', toReview_other: '{{count}} modules with artifacts to review', }, @@ -289,8 +292,23 @@ export const x2aPluginMessages = { success: 'Success', error: 'Error', cancelled: 'Cancelled', + removed: 'Removed', }, }, + resyncMigrationPlan: { + action: 'Resync migration plan', + confirm: { + title: 'Resync migration plan for "{{name}}"?', + message: + 'This will re-read the migration plan from the target repository and update the module list accordingly. New modules will be added and modules no longer in the plan will be marked as removed. If making changes to the document, like removing a module, make sure the document stays coherent.', + warning: + 'Modules marked as removed will retain their job history but will no longer be eligible for new phase runs. This action cannot be undone for removed modules unless they are re-added to the migration plan.', + confirmButton: 'Resync', + }, + running: 'Resyncing module list from migration plan…', + error: 'Failed to resync migration plan for project "{{name}}"', + errorStart: 'Failed to start migration plan resync', + }, artifact: { types: { migration_plan: 'Project Migration Plan',