From a8a37d255b029c4f25d66dc51bc7e8da40e00bc2 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 01:40:21 +0530 Subject: [PATCH 1/9] feat: project update command --- messages/devops.project.update.md | 41 ++++ schemas/devops-project-update.json | 28 +++ src/commands/devops/project/update.ts | 105 ++++++++++ src/utils/updateProject.ts | 67 ++++++ test/commands/devops/project/update.test.ts | 218 ++++++++++++++++++++ 5 files changed, 459 insertions(+) create mode 100644 messages/devops.project.update.md create mode 100644 schemas/devops-project-update.json create mode 100644 src/commands/devops/project/update.ts create mode 100644 src/utils/updateProject.ts create mode 100644 test/commands/devops/project/update.test.ts diff --git a/messages/devops.project.update.md b/messages/devops.project.update.md new file mode 100644 index 0000000..edc1f84 --- /dev/null +++ b/messages/devops.project.update.md @@ -0,0 +1,41 @@ +# summary + +Update a DevOps Center project. + +# description + +Update the description or active status of a DevOps Center project. At least one of --description or --is-active must be provided. + +# flags.project-name.summary + +Name of the DevOps Center project to update. + +# flags.project-id.summary + +ID of the DevOps Center project to update. + +# flags.description.summary + +New description for the project. + +# flags.is-active.summary + +Set the project active status. Use --is-active to activate or --no-is-active to deactivate. + +# examples + +- Update the description of a project by name: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-name "MyApp Release" --description "Updated release description" + +- Deactivate a project by ID: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --no-is-active + +- Update both description and active status: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-name "MyApp Release" --description "Archived" --no-is-active + +# error.NoFieldsProvided + +Provide at least one of --description or --is-active/--no-is-active. diff --git a/schemas/devops-project-update.json b/schemas/devops-project-update.json new file mode 100644 index 0000000..2b3f5a9 --- /dev/null +++ b/schemas/devops-project-update.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/UpdateProjectResult", + "definitions": { + "UpdateProjectResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projectId": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/project/update.ts b/src/commands/devops/project/update.ts new file mode 100644 index 0000000..f75971a --- /dev/null +++ b/src/commands/devops/project/update.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { resolveProjectByName, updateProject, UpdateProjectResult } from '../../../utils/updateProject.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.project.update'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsProjectUpdate extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'project-name': Flags.string({ + summary: messages.getMessage('flags.project-name.summary'), + char: 'n', + exactlyOne: ['project-name', 'project-id'], + }), + 'project-id': Flags.salesforceId({ + summary: messages.getMessage('flags.project-id.summary'), + char: 'i', + exactlyOne: ['project-name', 'project-id'], + }), + description: Flags.string({ + summary: messages.getMessage('flags.description.summary'), + char: 'd', + }), + 'is-active': Flags.boolean({ + summary: messages.getMessage('flags.is-active.summary'), + allowNo: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsProjectUpdate); + + if (flags['description'] === undefined && flags['is-active'] === undefined) { + this.error(messages.getMessage('error.NoFieldsProvided')); + } + + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + + let projectId: string; + try { + if (flags['project-name']) { + projectId = await resolveProjectByName(connection, flags['project-name']); + } else { + projectId = flags['project-id']!; + } + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + let result: UpdateProjectResult; + try { + result = await updateProject({ + connection, + projectId, + description: flags['description'], + isActive: flags['is-active'], + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + if (result.success) { + const identifier = flags['project-name'] ?? projectId; + this.log(`Successfully updated project: ${identifier}`); + if (result.description !== undefined) this.log(` Description: ${result.description}`); + if (result.isActive !== undefined) this.log(` IsActive: ${result.isActive}`); + } else { + this.error(`Failed to update project: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/utils/updateProject.ts b/src/utils/updateProject.ts new file mode 100644 index 0000000..cd33e10 --- /dev/null +++ b/src/utils/updateProject.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; + +export type UpdateProjectParams = { + connection: Connection; + projectId: string; + description?: string; + isActive?: boolean; +}; + +export type UpdateProjectResult = { + success: boolean; + projectId?: string; + description?: string; + isActive?: boolean; + error?: string; +}; + +export async function resolveProjectByName(connection: Connection, projectName: string): Promise { + const result = await connection.query<{ Id: string }>( + `SELECT Id FROM DevopsProject WHERE Name = '${projectName.replace(/'/g, "\\'")}' LIMIT 1` + ); + if (!result.records.length) { + throw new Error(`Project not found: ${projectName}`); + } + return result.records[0].Id; +} + +export async function updateProject(params: UpdateProjectParams): Promise { + const { connection, projectId, description, isActive } = params; + + const updatePayload: { Id: string; Description?: string; IsActive?: boolean } = { Id: projectId }; + if (description !== undefined) updatePayload.Description = description; + if (isActive !== undefined) updatePayload.IsActive = isActive; + + const result = await connection.sobject('DevopsProject').update(updatePayload); + + if (result.success) { + return { + success: true, + projectId, + description, + isActive, + }; + } + + const errorMessages = result.errors?.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))).join('; '); + return { + success: false, + error: errorMessages ?? 'Unknown error', + }; +} diff --git a/test/commands/devops/project/update.test.ts b/test/commands/devops/project/update.test.ts new file mode 100644 index 0000000..eab504a --- /dev/null +++ b/test/commands/devops/project/update.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops project update', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let UpdateCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; + const updateProjectStub = sinon.stub(); + const resolveProjectByNameStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/project/update.js', { + '../../../../src/utils/updateProject.js': { + updateProject: updateProjectStub, + resolveProjectByName: resolveProjectByNameStub, + }, + }); + UpdateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + updateProjectStub.reset(); + resolveProjectByNameStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful update by ID — description only', () => { + test + .stdout() + .stderr() + .it('logs success with updated description', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.resolves({ + success: true, + projectId: '1Qg000000000001', + description: 'New description', + }); + + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--description', + 'New description', + ]); + + expect(ctx.stdout).to.contain('Successfully updated project'); + expect(ctx.stdout).to.contain('New description'); + }); + }); + + describe('successful update by name — isActive only', () => { + test + .stdout() + .stderr() + .it('logs success with updated isActive', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + resolveProjectByNameStub.resolves('1Qg000000000001'); + updateProjectStub.resolves({ + success: true, + projectId: '1Qg000000000001', + isActive: false, + }); + + await UpdateCommand.run(['--target-org', 'testOrg', '--project-name', 'MyApp Release', '--no-is-active']); + + expect(ctx.stdout).to.contain('Successfully updated project: MyApp Release'); + expect(ctx.stdout).to.contain('IsActive: false'); + }); + }); + + describe('successful update — both fields', () => { + test + .stdout() + .stderr() + .it('logs both description and isActive', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.resolves({ + success: true, + projectId: '1Qg000000000001', + description: 'Archived', + isActive: false, + }); + + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--description', + 'Archived', + '--no-is-active', + ]); + + expect(ctx.stdout).to.contain('Description: Archived'); + expect(ctx.stdout).to.contain('IsActive: false'); + }); + }); + + describe('no update fields provided', () => { + test + .stdout() + .stderr() + .it('errors when neither description nor is-active is given', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('--description'); + }); + }); + + describe('update failure — sObject error', () => { + test + .stdout() + .stderr() + .it('shows failure error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.resolves({ + success: false, + error: 'FIELD_INTEGRITY_EXCEPTION', + }); + + try { + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--description', + 'Bad update', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Failed to update project'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error on resolve', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + resolveProjectByNameStub.rejects(new Error("sObject type 'DevopsProject' is not supported")); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--project-name', 'MyProject', '--description', 'test']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors from updateProject', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.rejects(new Error('Network error')); + + try { + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--description', + 'test', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); From 8f977844581a7bfad440f15442837103ef4a6747 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 01:55:05 +0530 Subject: [PATCH 2/9] chore: upadet command --- messages/devops.project.update.md | 26 +++++---- schemas/devops-project-update.json | 3 + src/commands/devops/project/update.ts | 37 ++++-------- src/utils/updateProject.ts | 18 ++---- test/commands/devops/project/update.test.ts | 64 ++++++++++++++++----- 5 files changed, 84 insertions(+), 64 deletions(-) diff --git a/messages/devops.project.update.md b/messages/devops.project.update.md index edc1f84..17bfa06 100644 --- a/messages/devops.project.update.md +++ b/messages/devops.project.update.md @@ -4,16 +4,16 @@ Update a DevOps Center project. # description -Update the description or active status of a DevOps Center project. At least one of --description or --is-active must be provided. - -# flags.project-name.summary - -Name of the DevOps Center project to update. +Update the name, description, or active status of a DevOps Center project. At least one of --name, --description, or --is-active must be provided. # flags.project-id.summary ID of the DevOps Center project to update. +# flags.name.summary + +New name for the project. + # flags.description.summary New description for the project. @@ -24,18 +24,22 @@ Set the project active status. Use --is-active to activate or --no-is-active to # examples -- Update the description of a project by name: +- Rename a project: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --name "MyApp Release v2" + +- Update the description of a project: - <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-name "MyApp Release" --description "Updated release description" + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --description "Updated release description" -- Deactivate a project by ID: +- Deactivate a project: <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --no-is-active -- Update both description and active status: +- Update all fields at once: - <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-name "MyApp Release" --description "Archived" --no-is-active + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --name "Archived App" --description "Archived" --no-is-active # error.NoFieldsProvided -Provide at least one of --description or --is-active/--no-is-active. +Provide at least one of --name, --description, or --is-active/--no-is-active. diff --git a/schemas/devops-project-update.json b/schemas/devops-project-update.json index 2b3f5a9..1be6211 100644 --- a/schemas/devops-project-update.json +++ b/schemas/devops-project-update.json @@ -11,6 +11,9 @@ "projectId": { "type": "string" }, + "name": { + "type": "string" + }, "description": { "type": "string" }, diff --git a/src/commands/devops/project/update.ts b/src/commands/devops/project/update.ts index f75971a..6dfef53 100644 --- a/src/commands/devops/project/update.ts +++ b/src/commands/devops/project/update.ts @@ -16,7 +16,7 @@ import { Messages, Org } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { resolveProjectByName, updateProject, UpdateProjectResult } from '../../../utils/updateProject.js'; +import { updateProject, UpdateProjectResult } from '../../../utils/updateProject.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.project.update'); @@ -30,15 +30,14 @@ export default class DevopsProjectUpdate extends SfCommand public static readonly flags = { 'target-org': Flags.requiredOrg(), 'api-version': Flags.orgApiVersion(), - 'project-name': Flags.string({ - summary: messages.getMessage('flags.project-name.summary'), - char: 'n', - exactlyOne: ['project-name', 'project-id'], - }), 'project-id': Flags.salesforceId({ summary: messages.getMessage('flags.project-id.summary'), char: 'i', - exactlyOne: ['project-name', 'project-id'], + required: true, + }), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + char: 'n', }), description: Flags.string({ summary: messages.getMessage('flags.description.summary'), @@ -53,33 +52,19 @@ export default class DevopsProjectUpdate extends SfCommand public async run(): Promise { const { flags } = await this.parse(DevopsProjectUpdate); - if (flags['description'] === undefined && flags['is-active'] === undefined) { + if (flags['name'] === undefined && flags['description'] === undefined && flags['is-active'] === undefined) { this.error(messages.getMessage('error.NoFieldsProvided')); } const org: Org = flags['target-org']; const connection = org.getConnection(flags['api-version']); - let projectId: string; - try { - if (flags['project-name']) { - projectId = await resolveProjectByName(connection, flags['project-name']); - } else { - projectId = flags['project-id']!; - } - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { - this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); - } - throw error; - } - let result: UpdateProjectResult; try { result = await updateProject({ connection, - projectId, + projectId: flags['project-id'], + name: flags['name'], description: flags['description'], isActive: flags['is-active'], }); @@ -92,8 +77,8 @@ export default class DevopsProjectUpdate extends SfCommand } if (result.success) { - const identifier = flags['project-name'] ?? projectId; - this.log(`Successfully updated project: ${identifier}`); + this.log(`Successfully updated project: ${flags['project-id']}`); + if (result.name !== undefined) this.log(` Name: ${result.name}`); if (result.description !== undefined) this.log(` Description: ${result.description}`); if (result.isActive !== undefined) this.log(` IsActive: ${result.isActive}`); } else { diff --git a/src/utils/updateProject.ts b/src/utils/updateProject.ts index cd33e10..85bbf1a 100644 --- a/src/utils/updateProject.ts +++ b/src/utils/updateProject.ts @@ -19,6 +19,7 @@ import { Connection } from '@salesforce/core'; export type UpdateProjectParams = { connection: Connection; projectId: string; + name?: string; description?: string; isActive?: boolean; }; @@ -26,25 +27,17 @@ export type UpdateProjectParams = { export type UpdateProjectResult = { success: boolean; projectId?: string; + name?: string; description?: string; isActive?: boolean; error?: string; }; -export async function resolveProjectByName(connection: Connection, projectName: string): Promise { - const result = await connection.query<{ Id: string }>( - `SELECT Id FROM DevopsProject WHERE Name = '${projectName.replace(/'/g, "\\'")}' LIMIT 1` - ); - if (!result.records.length) { - throw new Error(`Project not found: ${projectName}`); - } - return result.records[0].Id; -} - export async function updateProject(params: UpdateProjectParams): Promise { - const { connection, projectId, description, isActive } = params; + const { connection, projectId, name, description, isActive } = params; - const updatePayload: { Id: string; Description?: string; IsActive?: boolean } = { Id: projectId }; + const updatePayload: { Id: string; Name?: string; Description?: string; IsActive?: boolean } = { Id: projectId }; + if (name !== undefined) updatePayload.Name = name; if (description !== undefined) updatePayload.Description = description; if (isActive !== undefined) updatePayload.IsActive = isActive; @@ -54,6 +47,7 @@ export async function updateProject(params: UpdateProjectParams): Promise { const mockConnection = { getApiVersion: () => '65.0' }; const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; const updateProjectStub = sinon.stub(); - const resolveProjectByNameStub = sinon.stub(); before(async () => { const mod = await esmock('../../../../src/commands/devops/project/update.js', { '../../../../src/utils/updateProject.js': { updateProject: updateProjectStub, - resolveProjectByName: resolveProjectByNameStub, }, }); UpdateCommand = mod.default; @@ -41,14 +39,40 @@ describe('devops project update', () => { beforeEach(() => { sandbox = sinon.createSandbox(); updateProjectStub.reset(); - resolveProjectByNameStub.reset(); }); afterEach(() => { sandbox.restore(); }); - describe('successful update by ID — description only', () => { + describe('successful update — name only', () => { + test + .stdout() + .stderr() + .it('logs success with updated name', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.resolves({ + success: true, + projectId: '1Qg000000000001', + name: 'MyApp Release v2', + }); + + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--name', + 'MyApp Release v2', + ]); + + expect(ctx.stdout).to.contain('Successfully updated project'); + expect(ctx.stdout).to.contain('MyApp Release v2'); + }); + }); + + describe('successful update — description only', () => { test .stdout() .stderr() @@ -75,37 +99,37 @@ describe('devops project update', () => { }); }); - describe('successful update by name — isActive only', () => { + describe('successful update — isActive only', () => { test .stdout() .stderr() .it('logs success with updated isActive', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - resolveProjectByNameStub.resolves('1Qg000000000001'); updateProjectStub.resolves({ success: true, projectId: '1Qg000000000001', isActive: false, }); - await UpdateCommand.run(['--target-org', 'testOrg', '--project-name', 'MyApp Release', '--no-is-active']); + await UpdateCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001', '--no-is-active']); - expect(ctx.stdout).to.contain('Successfully updated project: MyApp Release'); + expect(ctx.stdout).to.contain('Successfully updated project'); expect(ctx.stdout).to.contain('IsActive: false'); }); }); - describe('successful update — both fields', () => { + describe('successful update — all fields', () => { test .stdout() .stderr() - .it('logs both description and isActive', async (ctx) => { + .it('logs name, description, and isActive', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); updateProjectStub.resolves({ success: true, projectId: '1Qg000000000001', + name: 'Archived App', description: 'Archived', isActive: false, }); @@ -115,11 +139,14 @@ describe('devops project update', () => { 'testOrg', '--project-id', '1Qg000000000001', + '--name', + 'Archived App', '--description', 'Archived', '--no-is-active', ]); + expect(ctx.stdout).to.contain('Name: Archived App'); expect(ctx.stdout).to.contain('Description: Archived'); expect(ctx.stdout).to.contain('IsActive: false'); }); @@ -129,7 +156,7 @@ describe('devops project update', () => { test .stdout() .stderr() - .it('errors when neither description nor is-active is given', async (ctx) => { + .it('errors when no update fields are given', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); @@ -139,7 +166,7 @@ describe('devops project update', () => { // expected } - expect(ctx.stderr).to.contain('--description'); + expect(ctx.stderr).to.contain('--name'); }); }); @@ -176,13 +203,20 @@ describe('devops project update', () => { test .stdout() .stderr() - .it('shows DevOps Center not enabled error on resolve', async (ctx) => { + .it('shows DevOps Center not enabled error', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - resolveProjectByNameStub.rejects(new Error("sObject type 'DevopsProject' is not supported")); + updateProjectStub.rejects(new Error("sObject type 'DevopsProject' is not supported")); try { - await UpdateCommand.run(['--target-org', 'testOrg', '--project-name', 'MyProject', '--description', 'test']); + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--description', + 'test', + ]); } catch (e) { // expected } From acab558b88902cb76ea8797f8ae4bf2ead684837 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 02:05:55 +0530 Subject: [PATCH 3/9] feat: pipeline list and get commands Add devops pipeline list (all pipelines) and devops pipeline get (single pipeline with stages, repositories, and connected projects). Regenerate schemas for all existing commands. Co-Authored-By: Claude Sonnet 4.6 --- messages/devops.pipeline.get.md | 17 +++ messages/devops.pipeline.list.md | 13 ++ schemas/devops-pipeline-get.json | 75 +++++++++ schemas/devops-pipeline-list.json | 38 +++++ src/commands/devops/pipeline/get.ts | 88 +++++++++++ src/commands/devops/pipeline/list.ts | 63 ++++++++ src/utils/getPipeline.ts | 125 +++++++++++++++ src/utils/listPipelines.ts | 33 ++++ test/commands/devops/pipeline/get.test.ts | 169 +++++++++++++++++++++ test/commands/devops/pipeline/list.test.ts | 120 +++++++++++++++ 10 files changed, 741 insertions(+) create mode 100644 messages/devops.pipeline.get.md create mode 100644 messages/devops.pipeline.list.md create mode 100644 schemas/devops-pipeline-get.json create mode 100644 schemas/devops-pipeline-list.json create mode 100644 src/commands/devops/pipeline/get.ts create mode 100644 src/commands/devops/pipeline/list.ts create mode 100644 src/utils/getPipeline.ts create mode 100644 src/utils/listPipelines.ts create mode 100644 test/commands/devops/pipeline/get.test.ts create mode 100644 test/commands/devops/pipeline/list.test.ts diff --git a/messages/devops.pipeline.get.md b/messages/devops.pipeline.get.md new file mode 100644 index 0000000..3416566 --- /dev/null +++ b/messages/devops.pipeline.get.md @@ -0,0 +1,17 @@ +# summary + +Get details of a DevOps Center pipeline including its stages, repositories, and connected projects. + +# description + +Returns full details for a single DevOps Center pipeline: its stages in order, the source code repository and branch associated with each stage, and any connected projects. + +# flags.pipeline-id.summary + +ID of the DevOps Center pipeline. + +# examples + +- Get details for a specific pipeline: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Do000000000001 diff --git a/messages/devops.pipeline.list.md b/messages/devops.pipeline.list.md new file mode 100644 index 0000000..8cb7b1c --- /dev/null +++ b/messages/devops.pipeline.list.md @@ -0,0 +1,13 @@ +# summary + +List DevOps Center pipelines with their stages, repositories, and connected projects. + +# description + +Returns all DevOps Center pipelines in the org, including each pipeline's stages (in order), source code repository information per stage, and any connected projects. + +# examples + +- List all pipelines in the org: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org diff --git a/schemas/devops-pipeline-get.json b/schemas/devops-pipeline-get.json new file mode 100644 index 0000000..a892c01 --- /dev/null +++ b/schemas/devops-pipeline-get.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PipelineGetResult", + "definitions": { + "PipelineGetResult": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "isActive": { + "type": "boolean" + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/definitions/PipelineStageDetail" + } + }, + "connectedProjects": { + "type": "array", + "items": { + "$ref": "#/definitions/ConnectedProject" + } + } + }, + "required": ["id", "name", "description", "isActive", "stages", "connectedProjects"], + "additionalProperties": false + }, + "PipelineStageDetail": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + }, + "nextStageId": { + "type": ["string", "null"] + }, + "branchName": { + "type": ["string", "null"] + }, + "repositoryName": { + "type": ["string", "null"] + }, + "repositoryOwner": { + "type": ["string", "null"] + } + }, + "required": ["id", "name", "nextStageId", "branchName", "repositoryName", "repositoryOwner"], + "additionalProperties": false + }, + "ConnectedProject": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-pipeline-list.json b/schemas/devops-pipeline-list.json new file mode 100644 index 0000000..3e73775 --- /dev/null +++ b/schemas/devops-pipeline-list.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PipelineListResult", + "definitions": { + "PipelineListResult": { + "type": "object", + "properties": { + "pipelines": { + "type": "array", + "items": { + "$ref": "#/definitions/DevopsPipeline" + } + } + }, + "required": ["pipelines"], + "additionalProperties": false + }, + "DevopsPipeline": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Description": { + "type": ["string", "null"] + }, + "IsActive": { + "type": "boolean" + } + }, + "required": ["Id", "Name", "Description", "IsActive"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/get.ts b/src/commands/devops/pipeline/get.ts new file mode 100644 index 0000000..3f60202 --- /dev/null +++ b/src/commands/devops/pipeline/get.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { getPipeline, PipelineGetResult } from '../../../utils/getPipeline.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.get'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineGet extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'pipeline-id': Flags.salesforceId({ + summary: messages.getMessage('flags.pipeline-id.summary'), + char: 'i', + required: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineGet); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + + let result: PipelineGetResult; + try { + result = await getPipeline(connection, flags['pipeline-id']); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + this.styledHeader(`Pipeline: ${result.name}`); + this.log(` ID: ${result.id}`); + this.log(` Description: ${result.description ?? ''}`); + this.log(` Active: ${result.isActive}`); + + if (result.stages.length > 0) { + this.log(''); + this.styledHeader('Stages'); + this.table({ + data: result.stages.map((s) => ({ + ID: s.id, + Name: s.name ?? '', + 'Next Stage ID': s.nextStageId ?? '', + Branch: s.branchName ?? '', + Repository: + s.repositoryOwner && s.repositoryName ? `${s.repositoryOwner}/${s.repositoryName}` : s.repositoryName ?? '', + })), + columns: ['ID', 'Name', 'Next Stage ID', 'Branch', 'Repository'], + }); + } + + if (result.connectedProjects.length > 0) { + this.log(''); + this.styledHeader('Connected Projects'); + this.table({ + data: result.connectedProjects.map((p) => ({ ID: p.id, Name: p.name ?? '' })), + columns: ['ID', 'Name'], + }); + } + + return result; + } +} diff --git a/src/commands/devops/pipeline/list.ts b/src/commands/devops/pipeline/list.ts new file mode 100644 index 0000000..eb39d56 --- /dev/null +++ b/src/commands/devops/pipeline/list.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { listPipelines, PipelineListResult } from '../../../utils/listPipelines.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.list'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineList extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineList); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + + let result: PipelineListResult; + try { + result = await listPipelines(connection); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + if (result.pipelines.length === 0) { + this.log('No DevOps Center pipelines found in this org.'); + } else { + this.styledHeader('DevOps Center Pipelines'); + this.table({ + data: result.pipelines, + columns: ['Id', 'Name', 'Description', 'IsActive'], + }); + } + + return result; + } +} diff --git a/src/utils/getPipeline.ts b/src/utils/getPipeline.ts new file mode 100644 index 0000000..42b2eaf --- /dev/null +++ b/src/utils/getPipeline.ts @@ -0,0 +1,125 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; + +type DevopsPipelineRecord = { + Id: string; + Name: string; + Description: string | null; + IsActive: boolean; +}; + +type StageQueryRecord = { + Id: string; + Name: string | null; + NextStageId: string | null; + DevopsPipelineId: string; + SourceCodeRepositoryBranch: { + Name: string | null; + SourceCodeRepository: { + Name: string | null; + RepositoryOwner: string | null; + } | null; + } | null; +}; + +type ProjectPipelineRecord = { + DevopsProjectId: string; + DevopsProject: { Name: string } | null; +}; + +export type PipelineStageDetail = { + id: string; + name: string | null; + nextStageId: string | null; + branchName: string | null; + repositoryName: string | null; + repositoryOwner: string | null; +}; + +export type ConnectedProject = { + id: string; + name: string | null; +}; + +export type PipelineGetResult = { + id: string; + name: string; + description: string | null; + isActive: boolean; + stages: PipelineStageDetail[]; + connectedProjects: ConnectedProject[]; +}; + +function orderStages(stages: PipelineStageDetail[]): PipelineStageDetail[] { + if (stages.length <= 1) return stages; + const nextIds = new Set(stages.map((s) => s.nextStageId).filter(Boolean)); + const first = stages.find((s) => !nextIds.has(s.id)); + if (!first) return stages; + const byId = new Map(stages.map((s) => [s.id, s])); + const ordered: PipelineStageDetail[] = []; + let current: PipelineStageDetail | undefined = first; + while (current) { + ordered.push(current); + current = current.nextStageId ? byId.get(current.nextStageId) : undefined; + } + return ordered; +} + +export async function getPipeline(connection: Connection, pipelineId: string): Promise { + const pipelineResult = await connection.query( + `SELECT Id, Name, Description, IsActive FROM DevopsPipeline WHERE Id = '${pipelineId}' LIMIT 1` + ); + const pipeline = (pipelineResult.records ?? [])[0]; + if (!pipeline) { + throw new Error(`Pipeline not found: ${pipelineId}`); + } + + const [stageResult, junctionResult] = await Promise.all([ + connection.query( + `SELECT Id, Name, NextStageId, DevopsPipelineId, SourceCodeRepositoryBranch.Name, SourceCodeRepositoryBranch.SourceCodeRepository.Name, SourceCodeRepositoryBranch.SourceCodeRepository.RepositoryOwner FROM DevopsPipelineStage WHERE DevopsPipelineId = '${pipelineId}'` + ), + connection.query( + `SELECT DevopsProjectId, DevopsProject.Name FROM DevopsProjectPipeline WHERE DevopsPipelineId = '${pipelineId}'` + ), + ]); + + const stages = orderStages( + (stageResult.records ?? []).map((s) => ({ + id: s.Id, + name: s.Name, + nextStageId: s.NextStageId ?? null, + branchName: s.SourceCodeRepositoryBranch?.Name ?? null, + repositoryName: s.SourceCodeRepositoryBranch?.SourceCodeRepository?.Name ?? null, + repositoryOwner: s.SourceCodeRepositoryBranch?.SourceCodeRepository?.RepositoryOwner ?? null, + })) + ); + + const connectedProjects = (junctionResult.records ?? []).map((j) => ({ + id: j.DevopsProjectId, + name: j.DevopsProject?.Name ?? null, + })); + + return { + id: pipeline.Id, + name: pipeline.Name, + description: pipeline.Description, + isActive: pipeline.IsActive, + stages, + connectedProjects, + }; +} diff --git a/src/utils/listPipelines.ts b/src/utils/listPipelines.ts new file mode 100644 index 0000000..c211c1c --- /dev/null +++ b/src/utils/listPipelines.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; + +export type DevopsPipeline = { + Id: string; + Name: string; + Description: string | null; + IsActive: boolean; +}; + +export type PipelineListResult = { + pipelines: DevopsPipeline[]; +}; + +export async function listPipelines(connection: Connection): Promise { + const result = await connection.query('SELECT Id, Name, Description, IsActive FROM DevopsPipeline'); + return { pipelines: result.records ?? [] }; +} diff --git a/test/commands/devops/pipeline/get.test.ts b/test/commands/devops/pipeline/get.test.ts new file mode 100644 index 0000000..4fca54b --- /dev/null +++ b/test/commands/devops/pipeline/get.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline get', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let GetCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; + const getPipelineStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/pipeline/get.js', { + '../../../../src/utils/getPipeline.js': { + getPipeline: getPipelineStub, + }, + }); + GetCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getPipelineStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('returns pipeline details', () => { + test + .stdout() + .stderr() + .it('displays pipeline info, stages, and projects', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineStub.resolves({ + id: '0Do000000000001', + name: 'Main Pipeline', + description: 'Primary release pipeline', + isActive: true, + stages: [ + { + id: 'stage1', + name: 'Integration', + nextStageId: 'stage2', + branchName: 'int', + repositoryName: 'myrepo', + repositoryOwner: 'myorg', + }, + { + id: 'stage2', + name: 'Production', + nextStageId: null, + branchName: 'main', + repositoryName: 'myrepo', + repositoryOwner: 'myorg', + }, + ], + connectedProjects: [{ id: 'proj1', name: 'MyApp' }], + }); + + await GetCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0Do000000000001']); + + expect(ctx.stdout).to.contain('Main Pipeline'); + expect(ctx.stdout).to.contain('Primary release pipeline'); + expect(ctx.stdout).to.contain('Integration'); + expect(ctx.stdout).to.contain('Production'); + expect(ctx.stdout).to.contain('myorg/myrepo'); + expect(ctx.stdout).to.contain('MyApp'); + }); + }); + + describe('pipeline with no stages or projects', () => { + test + .stdout() + .stderr() + .it('displays pipeline info only', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineStub.resolves({ + id: '0Do000000000002', + name: 'Empty Pipeline', + description: null, + isActive: false, + stages: [], + connectedProjects: [], + }); + + await GetCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0Do000000000002']); + + expect(ctx.stdout).to.contain('Empty Pipeline'); + expect(ctx.stdout).to.contain('Active: false'); + }); + }); + + describe('pipeline not found', () => { + test + .stdout() + .stderr() + .it('throws not found error', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineStub.rejects(new Error('Pipeline not found: 0Do000000000099')); + + try { + await GetCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0Do000000000099']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Pipeline not found'); + } + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineStub.rejects(new Error("sObject type 'DevopsPipeline' is not supported")); + + try { + await GetCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0Do000000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineStub.rejects(new Error('Network error')); + + try { + await GetCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0Do000000000001']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/pipeline/list.test.ts b/test/commands/devops/pipeline/list.test.ts new file mode 100644 index 0000000..8b534cc --- /dev/null +++ b/test/commands/devops/pipeline/list.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2026, Salesforce, 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 { expect, test } from '@oclif/test'; +import { TestContext } from '@salesforce/core/testSetup'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline list', () => { + const $$ = new TestContext(); + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + $$.restore(); + }); + + describe('lists pipelines', () => { + test + .stdout() + .do(() => { + const queryStub = sandbox.stub().resolves({ + records: [ + { Id: '0Do000000000001', Name: 'Main Pipeline', Description: 'Primary', IsActive: true }, + { Id: '0Do000000000002', Name: 'Hotfix Pipeline', Description: null, IsActive: false }, + ], + }); + const mockOrg = { + id: '1', + getOrgId: () => '1', + getConnection: () => ({ query: queryStub, getApiVersion: () => '65.0' }), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }) + .command(['devops:pipeline:list', '--target-org', 'testOrg']) + .it('displays pipeline names', (ctx) => { + expect(ctx.stdout).to.contain('Main Pipeline'); + expect(ctx.stdout).to.contain('Hotfix Pipeline'); + }); + }); + + describe('no pipelines found', () => { + test + .stdout() + .do(() => { + const queryStub = sandbox.stub().resolves({ records: [] }); + const mockOrg = { + id: '1', + getOrgId: () => '1', + getConnection: () => ({ query: queryStub, getApiVersion: () => '65.0' }), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }) + .command(['devops:pipeline:list', '--target-org', 'testOrg']) + .it('logs no pipelines message', (ctx) => { + expect(ctx.stdout).to.contain('No DevOps Center pipelines found'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .do(() => { + const queryStub = sandbox.stub().rejects(new Error("sObject type 'DevopsPipeline' is not supported")); + const mockOrg = { + id: '1', + getOrgId: () => '1', + getConnection: () => ({ query: queryStub, getApiVersion: () => '65.0' }), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }) + .command(['devops:pipeline:list', '--target-org', 'testOrg']) + .catch(() => {}) + .it('shows DevOps Center not enabled error', (ctx) => { + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .do(() => { + const queryStub = sandbox.stub().rejects(new Error('Network error')); + const mockOrg = { + id: '1', + getOrgId: () => '1', + getConnection: () => ({ query: queryStub, getApiVersion: () => '65.0' }), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }) + .command(['devops:pipeline:list', '--target-org', 'testOrg']) + .catch((err) => { + expect(err.message).to.contain('Network error'); + }) + .it('rethrows non-DevOps errors', () => {}); + }); +}); From 2dd4fda4fdffa1e1a42f2d5baa526583defac68d Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 13:14:56 +0530 Subject: [PATCH 4/9] feat: stage and environment delete --- messages/devops.pipeline.stage.delete.md | 25 +++ messages/devops.stage.environment.delete.md | 29 +++ schemas/devops-pipeline-stage-delete.json | 25 +++ schemas/devops-stage-environment-delete.json | 25 +++ src/commands/devops/pipeline/get.ts | 4 +- src/commands/devops/pipeline/stage/delete.ts | 74 +++++++ .../devops/stage/environment/delete.ts | 82 ++++++++ src/utils/deletePipelineStage.ts | 58 ++++++ src/utils/deleteStageEnvironment.ts | 49 +++++ src/utils/getPipeline.ts | 13 +- test/commands/devops/pipeline/get.test.ts | 4 + .../devops/pipeline/stage/delete.test.ts | 180 ++++++++++++++++++ .../devops/stage/environment/delete.test.ts | 155 +++++++++++++++ 13 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 messages/devops.pipeline.stage.delete.md create mode 100644 messages/devops.stage.environment.delete.md create mode 100644 schemas/devops-pipeline-stage-delete.json create mode 100644 schemas/devops-stage-environment-delete.json create mode 100644 src/commands/devops/pipeline/stage/delete.ts create mode 100644 src/commands/devops/stage/environment/delete.ts create mode 100644 src/utils/deletePipelineStage.ts create mode 100644 src/utils/deleteStageEnvironment.ts create mode 100644 test/commands/devops/pipeline/stage/delete.test.ts create mode 100644 test/commands/devops/stage/environment/delete.test.ts diff --git a/messages/devops.pipeline.stage.delete.md b/messages/devops.pipeline.stage.delete.md new file mode 100644 index 0000000..d082d80 --- /dev/null +++ b/messages/devops.pipeline.stage.delete.md @@ -0,0 +1,25 @@ +# summary + +Delete a stage from a DevOps Center pipeline. + +# description + +Deletes the specified stage from the pipeline. If the stage sits between two other stages, the predecessor stage is automatically re-linked to the successor so the pipeline chain stays intact. + +# flags.pipeline-id.summary + +ID of the pipeline that contains the stage. + +# flags.stage-id.summary + +ID of the stage to delete. + +# examples + +- Delete a stage from a pipeline: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --stage-id 0Xc000000000002 + +# error.StageNotFound + +Stage %s not found in pipeline %s. Check the stage ID and try again. diff --git a/messages/devops.stage.environment.delete.md b/messages/devops.stage.environment.delete.md new file mode 100644 index 0000000..5a5f7ef --- /dev/null +++ b/messages/devops.stage.environment.delete.md @@ -0,0 +1,29 @@ +# summary + +Delete an environment from a DevOps Center pipeline stage. + +# description + +Removes the specified environment from the pipeline stage. The environment must belong to an inactive pipeline. + +# flags.pipeline-id.summary + +ID of the pipeline. Used to verify the pipeline is inactive before deleting. + +# flags.environment-id.summary + +ID of the environment to delete. + +# examples + +- Delete an environment: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --environment-id 0Xe000000000001 + +# error.PipelineAlreadyActive + +Pipeline %s is already active. Environments can only be removed from inactive pipelines. + +# error.EnvironmentNotFound + +Environment %s not found. Check the environment ID and try again. diff --git a/schemas/devops-pipeline-stage-delete.json b/schemas/devops-pipeline-stage-delete.json new file mode 100644 index 0000000..ee19770 --- /dev/null +++ b/schemas/devops-pipeline-stage-delete.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DeletePipelineStageResult", + "definitions": { + "DeletePipelineStageResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "pipelineId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-stage-environment-delete.json b/schemas/devops-stage-environment-delete.json new file mode 100644 index 0000000..9a908c5 --- /dev/null +++ b/schemas/devops-stage-environment-delete.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DeleteStageEnvironmentResult", + "definitions": { + "DeleteStageEnvironmentResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "environmentId": { + "type": "string" + }, + "stageId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/get.ts b/src/commands/devops/pipeline/get.ts index 3f60202..a7a3dc3 100644 --- a/src/commands/devops/pipeline/get.ts +++ b/src/commands/devops/pipeline/get.ts @@ -69,8 +69,10 @@ export default class DevopsPipelineGet extends SfCommand { Branch: s.branchName ?? '', Repository: s.repositoryOwner && s.repositoryName ? `${s.repositoryOwner}/${s.repositoryName}` : s.repositoryName ?? '', + Environment: s.environment?.name ?? '', + 'Environment ID': s.environment?.id ?? '', })), - columns: ['ID', 'Name', 'Next Stage ID', 'Branch', 'Repository'], + columns: ['ID', 'Name', 'Next Stage ID', 'Branch', 'Repository', 'Environment', 'Environment ID'], }); } diff --git a/src/commands/devops/pipeline/stage/delete.ts b/src/commands/devops/pipeline/stage/delete.ts new file mode 100644 index 0000000..227c073 --- /dev/null +++ b/src/commands/devops/pipeline/stage/delete.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { deletePipelineStage, DeletePipelineStageResult } from '../../../../utils/deletePipelineStage.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.stage.delete'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineStageDelete extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'pipeline-id': Flags.salesforceId({ + summary: messages.getMessage('flags.pipeline-id.summary'), + required: true, + char: undefined, + }), + 'stage-id': Flags.salesforceId({ + summary: messages.getMessage('flags.stage-id.summary'), + required: true, + char: undefined, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineStageDelete); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const stageId = flags['stage-id']; + + let result: DeletePipelineStageResult; + try { + result = await deletePipelineStage(connection, pipelineId, stageId); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + if (errMsg.startsWith('Stage not found:') || errMsg.includes('entity is deleted')) { + this.error(messages.getMessage('error.StageNotFound', [stageId, pipelineId])); + } + throw error; + } + + if (result.success) { + this.log(`Successfully deleted stage ${stageId} from pipeline ${pipelineId}.`); + } else { + this.error(`Failed to delete stage: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/commands/devops/stage/environment/delete.ts b/src/commands/devops/stage/environment/delete.ts new file mode 100644 index 0000000..5d04888 --- /dev/null +++ b/src/commands/devops/stage/environment/delete.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { deleteStageEnvironment, DeleteStageEnvironmentResult } from '../../../../utils/deleteStageEnvironment.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.stage.environment.delete'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsStageEnvironmentDelete extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'pipeline-id': Flags.salesforceId({ + summary: messages.getMessage('flags.pipeline-id.summary'), + required: true, + char: undefined, + }), + 'environment-id': Flags.salesforceId({ + summary: messages.getMessage('flags.environment-id.summary'), + required: true, + char: 'e', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsStageEnvironmentDelete); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const environmentId = flags['environment-id']; + + const pipelineQueryResult = await connection.query( + `SELECT IsActive FROM DevopsPipeline WHERE Id = '${pipelineId}' LIMIT 1` + ); + const pipelineRecord = (pipelineQueryResult.records ?? [])[0] as { IsActive?: boolean } | undefined; + if (pipelineRecord?.IsActive) { + this.error(messages.getMessage('error.PipelineAlreadyActive', [pipelineId])); + } + + let result: DeleteStageEnvironmentResult; + try { + result = await deleteStageEnvironment(connection, environmentId); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + if (errMsg.includes('entity is deleted')) { + this.error(messages.getMessage('error.EnvironmentNotFound', [environmentId])); + } + throw error; + } + + if (result.success) { + this.log(`Successfully deleted environment ${environmentId}.`); + } else { + this.error(`Failed to delete environment: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/utils/deletePipelineStage.ts b/src/utils/deletePipelineStage.ts new file mode 100644 index 0000000..48739c8 --- /dev/null +++ b/src/utils/deletePipelineStage.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; +import { fetchPipelineStages } from './pipelineUtils.js'; + +export type DeletePipelineStageResult = { + success: boolean; + stageId?: string; + pipelineId?: string; + error?: string; +}; + +/** + * Deletes a pipeline stage and re-links any predecessor stage so the chain stays intact. + * If stage B sits between A→B→C, deleting B updates A's NextStageId to C before the delete. + */ +export async function deletePipelineStage( + connection: Connection, + pipelineId: string, + stageId: string +): Promise { + const stages = await fetchPipelineStages(connection, pipelineId); + const target = stages.find((s) => s.Id === stageId); + if (!target) { + throw new Error(`Stage not found: ${stageId}`); + } + + const predecessor = stages.find((s) => s.NextStageId === stageId); + if (predecessor) { + await connection + .sobject('DevopsPipelineStage') + .update({ Id: predecessor.Id, NextStageId: target.NextStageId ?? null }); + } + + const result = await connection.sobject('DevopsPipelineStage').delete(stageId); + const deleteResult = result as unknown as { success: boolean; errors?: Array<{ message: string }> }; + + if (!deleteResult.success) { + const errorMsg = deleteResult.errors?.map((e) => e.message).join('; ') ?? 'Unknown error'; + return { success: false, error: errorMsg }; + } + + return { success: true, stageId, pipelineId }; +} diff --git a/src/utils/deleteStageEnvironment.ts b/src/utils/deleteStageEnvironment.ts new file mode 100644 index 0000000..bdf9a6b --- /dev/null +++ b/src/utils/deleteStageEnvironment.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; + +export type DeleteStageEnvironmentResult = { + success: boolean; + environmentId?: string; + stageId?: string; + error?: string; +}; + +export async function deleteStageEnvironment( + connection: Connection, + environmentId: string +): Promise { + const stageResult = await connection.query<{ Id: string }>( + `SELECT Id FROM DevopsPipelineStage WHERE DevOpsEnvironmentId = '${environmentId}'` + ); + + const referencingStages = stageResult.records ?? []; + if (referencingStages.length > 0) { + const updates = referencingStages.map((s) => ({ Id: s.Id, DevOpsEnvironmentId: null })); + await connection.sobject('DevopsPipelineStage').update(updates); + } + + const result = await connection.sobject('DevopsEnvironment').delete(environmentId); + const deleteResult = result as unknown as { success: boolean; errors?: Array<{ message: string }> }; + + if (!deleteResult.success) { + const errorMsg = deleteResult.errors?.map((e) => e.message).join('; ') ?? 'Unknown error'; + return { success: false, error: errorMsg }; + } + + return { success: true, environmentId }; +} diff --git a/src/utils/getPipeline.ts b/src/utils/getPipeline.ts index 42b2eaf..1fc9e47 100644 --- a/src/utils/getPipeline.ts +++ b/src/utils/getPipeline.ts @@ -35,6 +35,10 @@ type StageQueryRecord = { RepositoryOwner: string | null; } | null; } | null; + DevOpsEnvironment: { + Id: string; + Name: string; + } | null; }; type ProjectPipelineRecord = { @@ -42,6 +46,11 @@ type ProjectPipelineRecord = { DevopsProject: { Name: string } | null; }; +export type StageEnvironment = { + id: string; + name: string; +}; + export type PipelineStageDetail = { id: string; name: string | null; @@ -49,6 +58,7 @@ export type PipelineStageDetail = { branchName: string | null; repositoryName: string | null; repositoryOwner: string | null; + environment: StageEnvironment | null; }; export type ConnectedProject = { @@ -91,7 +101,7 @@ export async function getPipeline(connection: Connection, pipelineId: string): P const [stageResult, junctionResult] = await Promise.all([ connection.query( - `SELECT Id, Name, NextStageId, DevopsPipelineId, SourceCodeRepositoryBranch.Name, SourceCodeRepositoryBranch.SourceCodeRepository.Name, SourceCodeRepositoryBranch.SourceCodeRepository.RepositoryOwner FROM DevopsPipelineStage WHERE DevopsPipelineId = '${pipelineId}'` + `SELECT Id, Name, NextStageId, DevopsPipelineId, SourceCodeRepositoryBranch.Name, SourceCodeRepositoryBranch.SourceCodeRepository.Name, SourceCodeRepositoryBranch.SourceCodeRepository.RepositoryOwner, DevOpsEnvironment.Id, DevOpsEnvironment.Name FROM DevopsPipelineStage WHERE DevopsPipelineId = '${pipelineId}'` ), connection.query( `SELECT DevopsProjectId, DevopsProject.Name FROM DevopsProjectPipeline WHERE DevopsPipelineId = '${pipelineId}'` @@ -106,6 +116,7 @@ export async function getPipeline(connection: Connection, pipelineId: string): P branchName: s.SourceCodeRepositoryBranch?.Name ?? null, repositoryName: s.SourceCodeRepositoryBranch?.SourceCodeRepository?.Name ?? null, repositoryOwner: s.SourceCodeRepositoryBranch?.SourceCodeRepository?.RepositoryOwner ?? null, + environment: s.DevOpsEnvironment ? { id: s.DevOpsEnvironment.Id, name: s.DevOpsEnvironment.Name } : null, })) ); diff --git a/test/commands/devops/pipeline/get.test.ts b/test/commands/devops/pipeline/get.test.ts index 4fca54b..ac21513 100644 --- a/test/commands/devops/pipeline/get.test.ts +++ b/test/commands/devops/pipeline/get.test.ts @@ -65,6 +65,7 @@ describe('devops pipeline get', () => { branchName: 'int', repositoryName: 'myrepo', repositoryOwner: 'myorg', + environment: { id: '0Xe000000000001', name: 'Integration_Org' }, }, { id: 'stage2', @@ -73,6 +74,7 @@ describe('devops pipeline get', () => { branchName: 'main', repositoryName: 'myrepo', repositoryOwner: 'myorg', + environment: null, }, ], connectedProjects: [{ id: 'proj1', name: 'MyApp' }], @@ -85,6 +87,8 @@ describe('devops pipeline get', () => { expect(ctx.stdout).to.contain('Integration'); expect(ctx.stdout).to.contain('Production'); expect(ctx.stdout).to.contain('myorg/myrepo'); + expect(ctx.stdout).to.contain('Integration_Org'); + expect(ctx.stdout).to.contain('0Xe000000000001'); expect(ctx.stdout).to.contain('MyApp'); }); }); diff --git a/test/commands/devops/pipeline/stage/delete.test.ts b/test/commands/devops/pipeline/stage/delete.test.ts new file mode 100644 index 0000000..3cd2fea --- /dev/null +++ b/test/commands/devops/pipeline/stage/delete.test.ts @@ -0,0 +1,180 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline stage delete', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let DeleteCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const deletePipelineStageStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/pipeline/stage/delete.js', { + '../../../../../src/utils/deletePipelineStage.js': { + deletePipelineStage: deletePipelineStageStub, + }, + }); + DeleteCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deletePipelineStageStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful deletion', () => { + test + .stdout() + .stderr() + .it('logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deletePipelineStageStub.resolves({ + success: true, + stageId: '0Xc000000000002', + pipelineId: '0XB000000000001', + }); + + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--stage-id', + '0Xc000000000002', + ]); + + expect(ctx.stdout).to.contain('Successfully deleted stage 0Xc000000000002'); + expect(ctx.stdout).to.contain('0XB000000000001'); + }); + }); + + describe('stage not found', () => { + test + .stdout() + .stderr() + .it('shows stage not found error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deletePipelineStageStub.rejects(new Error('Stage not found: 0Xc000000000099')); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--stage-id', + '0Xc000000000099', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('not found in pipeline'); + }); + }); + + describe('deletion failure', () => { + test + .stdout() + .stderr() + .it('shows failure error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deletePipelineStageStub.resolves({ + success: false, + error: 'ENTITY_IS_LOCKED', + }); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--stage-id', + '0Xc000000000002', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Failed to delete stage'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deletePipelineStageStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--stage-id', + '0Xc000000000002', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deletePipelineStageStub.rejects(new Error('Network error')); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--stage-id', + '0Xc000000000002', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/stage/environment/delete.test.ts b/test/commands/devops/stage/environment/delete.test.ts new file mode 100644 index 0000000..ce1ba63 --- /dev/null +++ b/test/commands/devops/stage/environment/delete.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops stage environment delete', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let DeleteCommand: any; + const deleteStageEnvironmentStub = sinon.stub(); + + const mockQueryStub = sinon.stub(); + const mockConnection = { + getApiVersion: () => '65.0', + query: mockQueryStub, + }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/stage/environment/delete.js', { + '../../../../../src/utils/deleteStageEnvironment.js': { + deleteStageEnvironment: deleteStageEnvironmentStub, + }, + }); + DeleteCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deleteStageEnvironmentStub.reset(); + mockQueryStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful deletion', () => { + test + .stdout() + .stderr() + .it('logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + deleteStageEnvironmentStub.resolves({ success: true, environmentId: '0Xe000000000001' }); + + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--environment-id', + '0Xe000000000001', + ]); + + expect(ctx.stdout).to.contain('Successfully deleted environment 0Xe000000000001'); + }); + }); + + describe('pipeline already active', () => { + test + .stdout() + .stderr() + .it('shows active pipeline error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: true }] }); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--environment-id', + '0Xe000000000001', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already active'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + deleteStageEnvironmentStub.rejects(new Error("sObject type 'DevopsEnvironment' is not supported")); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--environment-id', + '0Xe000000000001', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors from deleteStageEnvironment', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + deleteStageEnvironmentStub.rejects(new Error('Network error')); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--environment-id', + '0Xe000000000001', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); From 659bce5f3107f5ad2d54718893de7b50a7f9fc99 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 13:32:59 +0530 Subject: [PATCH 5/9] feat: pipeline project delete --- command-snapshot.json | 356 ++++++++++++++++-- messages/devops.pipeline.project.delete.md | 25 ++ schemas/devops-pipeline-get.json | 25 +- schemas/devops-pipeline-project-delete.json | 25 ++ .../devops/pipeline/project/delete.ts | 76 ++++ src/utils/detachProject.ts | 53 +++ .../devops/pipeline/project/delete.test.ts | 183 +++++++++ 7 files changed, 707 insertions(+), 36 deletions(-) create mode 100644 messages/devops.pipeline.project.delete.md create mode 100644 schemas/devops-pipeline-project-delete.json create mode 100644 src/commands/devops/pipeline/project/delete.ts create mode 100644 src/utils/detachProject.ts create mode 100644 test/commands/devops/pipeline/project/delete.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index 35ae8a3..18f57f3 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -3,7 +3,12 @@ "alias": [], "command": "devops:pipeline:create", "flagAliases": [], - "flagChars": ["d", "n", "o", "r"], + "flagChars": [ + "d", + "n", + "o", + "r" + ], "flags": [ "api-version", "bitbucket-project-key", @@ -20,51 +25,195 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:pipeline:get", + "flagAliases": [], + "flagChars": [ + "i", + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "pipeline-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:pipeline:list", + "flagAliases": [], + "flagChars": [ + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:pipeline:project:add", "flagAliases": [], - "flagChars": ["o"], - "flags": ["api-version", "flags-dir", "json", "pipeline-id", "project-id", "target-org"], + "flagChars": [ + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "pipeline-id", + "project-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:pipeline:project:delete", + "flagAliases": [], + "flagChars": [ + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "pipeline-id", + "project-id", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:stage:add", "flagAliases": [], - "flagChars": ["n", "o"], - "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "pipeline-id", "target-org"], + "flagChars": [ + "n", + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "name", + "next-stage-id", + "pipeline-id", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:update", "flagAliases": [], - "flagChars": ["n", "o"], - "flags": ["active", "api-version", "flags-dir", "json", "name", "pipeline-id", "target-org"], + "flagChars": [ + "n", + "o" + ], + "flags": [ + "active", + "api-version", + "flags-dir", + "json", + "name", + "pipeline-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:pipeline:stage:delete", + "flagAliases": [], + "flagChars": [ + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "pipeline-id", + "stage-id", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:project:create", "flagAliases": [], - "flagChars": ["d", "n", "o"], - "flags": ["api-version", "description", "flags-dir", "json", "name", "target-org"], + "flagChars": [ + "d", + "n", + "o" + ], + "flags": [ + "api-version", + "description", + "flags-dir", + "json", + "name", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:project:list", "flagAliases": [], - "flagChars": ["o"], - "flags": ["api-version", "flags-dir", "json", "target-org"], + "flagChars": [ + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:project:update", + "flagAliases": [], + "flagChars": [ + "d", + "i", + "n", + "o" + ], + "flags": [ + "api-version", + "description", + "flags-dir", + "is-active", + "json", + "name", + "project-id", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:promote", "flagAliases": [], - "flagChars": ["a", "i", "l", "o", "s", "t"], + "flagChars": [ + "a", + "i", + "l", + "o", + "s", + "t" + ], "flags": [ "api-version", "deploy-all", @@ -83,7 +232,13 @@ "alias": [], "command": "devops:promotion:complete", "flagAliases": [], - "flagChars": ["a", "i", "l", "o", "t"], + "flagChars": [ + "a", + "i", + "l", + "o", + "t" + ], "flags": [ "api-version", "deploy-all", @@ -101,23 +256,46 @@ "alias": [], "command": "devops:request:status", "flagAliases": [], - "flagChars": ["i", "o"], - "flags": ["api-version", "flags-dir", "json", "request-token", "target-org"], + "flagChars": [ + "i", + "o" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "request-token", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:review:create", "flagAliases": [], - "flagChars": ["n", "o", "w"], - "flags": ["api-version", "flags-dir", "json", "target-org", "work-item-id", "work-item-name"], + "flagChars": [ + "n", + "o", + "w" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "target-org", + "work-item-id", + "work-item-name" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:stage:branch:add", "flagAliases": [], - "flagChars": ["b", "o"], + "flagChars": [ + "b", + "o" + ], "flags": [ "api-version", "branch-name", @@ -134,7 +312,10 @@ "alias": [], "command": "devops:stage:environment:add", "flagAliases": [], - "flagChars": ["e", "o"], + "flagChars": [ + "e", + "o" + ], "flags": [ "api-version", "environment-name", @@ -152,7 +333,10 @@ "alias": [], "command": "devops:work-item:combine", "flagAliases": [], - "flagChars": ["o", "t"], + "flagChars": [ + "o", + "t" + ], "flags": [ "api-version", "child-work-item-id", @@ -164,35 +348,90 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:stage:environment:delete", + "flagAliases": [], + "flagChars": [ + "e", + "o" + ], + "flags": [ + "api-version", + "environment-id", + "flags-dir", + "json", + "pipeline-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:work-item:create", "flagAliases": [], - "flagChars": ["d", "o", "p", "s"], - "flags": ["api-version", "description", "flags-dir", "json", "project-id", "subject", "target-org"], + "flagChars": [ + "d", + "o", + "p", + "s" + ], + "flags": [ + "api-version", + "description", + "flags-dir", + "json", + "project-id", + "subject", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:list", "flagAliases": [], - "flagChars": ["o", "p"], - "flags": ["api-version", "flags-dir", "json", "project-id", "target-org"], + "flagChars": [ + "o", + "p" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "project-id", + "target-org" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:prepare", "flagAliases": [], - "flagChars": ["i", "o", "t"], - "flags": ["api-version", "flags-dir", "json", "target-org", "target-stage-id", "work-item-id"], + "flagChars": [ + "i", + "o", + "t" + ], + "flags": [ + "api-version", + "flags-dir", + "json", + "target-org", + "target-stage-id", + "work-item-id" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:update", "flagAliases": [], - "flagChars": ["n", "o", "w"], + "flagChars": [ + "n", + "o", + "w" + ], "flags": [ "api-version", "description", @@ -210,7 +449,12 @@ "alias": [], "command": "project:deploy:pipeline:quick", "flagAliases": [], - "flagChars": ["c", "i", "r", "w"], + "flagChars": [ + "c", + "i", + "r", + "w" + ], "flags": [ "async", "concise", @@ -228,23 +472,56 @@ "alias": [], "command": "project:deploy:pipeline:report", "flagAliases": [], - "flagChars": ["c", "i", "r"], - "flags": ["devops-center-username", "flags-dir", "job-id", "json", "use-most-recent"], + "flagChars": [ + "c", + "i", + "r" + ], + "flags": [ + "devops-center-username", + "flags-dir", + "job-id", + "json", + "use-most-recent" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "project:deploy:pipeline:resume", "flagAliases": [], - "flagChars": ["c", "i", "r", "w"], - "flags": ["concise", "devops-center-username", "flags-dir", "job-id", "json", "use-most-recent", "verbose", "wait"], + "flagChars": [ + "c", + "i", + "r", + "w" + ], + "flags": [ + "concise", + "devops-center-username", + "flags-dir", + "job-id", + "json", + "use-most-recent", + "verbose", + "wait" + ], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "project:deploy:pipeline:start", "flagAliases": [], - "flagChars": ["a", "b", "c", "l", "p", "t", "v", "w"], + "flagChars": [ + "a", + "b", + "c", + "l", + "p", + "t", + "v", + "w" + ], "flags": [ "async", "branch-name", @@ -266,7 +543,16 @@ "alias": [], "command": "project:deploy:pipeline:validate", "flagAliases": [], - "flagChars": ["a", "b", "c", "l", "p", "t", "v", "w"], + "flagChars": [ + "a", + "b", + "c", + "l", + "p", + "t", + "v", + "w" + ], "flags": [ "async", "branch-name", @@ -284,4 +570,4 @@ ], "plugin": "@salesforce/plugin-devops-center" } -] +] \ No newline at end of file diff --git a/messages/devops.pipeline.project.delete.md b/messages/devops.pipeline.project.delete.md new file mode 100644 index 0000000..795498a --- /dev/null +++ b/messages/devops.pipeline.project.delete.md @@ -0,0 +1,25 @@ +# summary + +Remove a DevOps Center project's connection to a pipeline. + +# description + +Deletes the junction record that connects the project to the pipeline. The project itself is not deleted. + +# flags.pipeline-id.summary + +ID of the pipeline. + +# flags.project-id.summary + +ID of the DevOps Center project. + +# examples + +- Remove a project from a pipeline using the project ID and pipeline ID. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --project-id 0Hn000000000001 + +# error.ProjectNotAttached + +Project %s is not attached to pipeline %s. diff --git a/schemas/devops-pipeline-get.json b/schemas/devops-pipeline-get.json index a892c01..8d4ddcf 100644 --- a/schemas/devops-pipeline-get.json +++ b/schemas/devops-pipeline-get.json @@ -53,9 +53,32 @@ }, "repositoryOwner": { "type": ["string", "null"] + }, + "environment": { + "anyOf": [ + { + "$ref": "#/definitions/StageEnvironment" + }, + { + "type": "null" + } + ] } }, - "required": ["id", "name", "nextStageId", "branchName", "repositoryName", "repositoryOwner"], + "required": ["id", "name", "nextStageId", "branchName", "repositoryName", "repositoryOwner", "environment"], + "additionalProperties": false + }, + "StageEnvironment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], "additionalProperties": false }, "ConnectedProject": { diff --git a/schemas/devops-pipeline-project-delete.json b/schemas/devops-pipeline-project-delete.json new file mode 100644 index 0000000..a2d6b07 --- /dev/null +++ b/schemas/devops-pipeline-project-delete.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DetachProjectResult", + "definitions": { + "DetachProjectResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projectId": { + "type": "string" + }, + "pipelineId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success", "projectId", "pipelineId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/project/delete.ts b/src/commands/devops/pipeline/project/delete.ts new file mode 100644 index 0000000..c1bdf08 --- /dev/null +++ b/src/commands/devops/pipeline/project/delete.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { detachProject, DetachProjectResult } from '../../../../utils/detachProject.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.project.delete'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineProjectDelete extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'pipeline-id': Flags.salesforceId({ + summary: messages.getMessage('flags.pipeline-id.summary'), + required: true, + char: undefined, + }), + 'project-id': Flags.salesforceId({ + summary: messages.getMessage('flags.project-id.summary'), + required: true, + char: undefined, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineProjectDelete); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const projectId = flags['project-id']; + const pipelineId = flags['pipeline-id']; + + let result: DetachProjectResult; + try { + result = await detachProject(connection, projectId, pipelineId); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + if (errMsg.startsWith('ProjectNotAttached:') || errMsg.includes('entity is deleted')) { + this.error(messages.getMessage('error.ProjectNotAttached', [projectId, pipelineId])); + } + throw error; + } + + if (result.success) { + this.log('Successfully removed project from pipeline.'); + this.log(` Project ID: ${projectId}`); + this.log(` Pipeline ID: ${pipelineId}`); + } else { + this.error(`Failed to remove project from pipeline: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/utils/detachProject.ts b/src/utils/detachProject.ts new file mode 100644 index 0000000..354b002 --- /dev/null +++ b/src/utils/detachProject.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; + +export type DetachProjectResult = { + success: boolean; + projectId: string; + pipelineId: string; + error?: string; +}; + +export async function detachProject( + connection: Connection, + projectId: string, + pipelineId: string +): Promise { + const queryResult = await connection.query<{ Id: string }>( + `SELECT Id FROM DevopsProjectPipeline WHERE DevopsProjectId = '${projectId}' AND DevopsPipelineId = '${pipelineId}' LIMIT 1` + ); + + const junction = (queryResult.records ?? [])[0]; + if (!junction) { + throw new Error(`ProjectNotAttached:${projectId}:${pipelineId}`); + } + + const result = await connection.sobject('DevopsProjectPipeline').delete(junction.Id); + + if (result.success) { + return { success: true, projectId, pipelineId }; + } + + const errorMessages = result.errors?.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))).join('; '); + return { + success: false, + projectId, + pipelineId, + error: errorMessages ?? 'Unknown error', + }; +} diff --git a/test/commands/devops/pipeline/project/delete.test.ts b/test/commands/devops/pipeline/project/delete.test.ts new file mode 100644 index 0000000..431cfcf --- /dev/null +++ b/test/commands/devops/pipeline/project/delete.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline project delete', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let DeleteCommand: any; + const detachProjectStub = sinon.stub(); + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/pipeline/project/delete.js', { + '../../../../../src/utils/detachProject.js': { + detachProject: detachProjectStub, + }, + }); + DeleteCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + detachProjectStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful removal', () => { + test + .stdout() + .stderr() + .it('logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detachProjectStub.resolves({ + success: true, + projectId: '0Hn000000000001', + pipelineId: '0XB000000000001', + }); + + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--project-id', + '0Hn000000000001', + ]); + + expect(ctx.stdout).to.contain('Successfully removed project from pipeline'); + expect(ctx.stdout).to.contain('0Hn000000000001'); + expect(ctx.stdout).to.contain('0XB000000000001'); + }); + }); + + describe('project not attached', () => { + test + .stdout() + .stderr() + .it('shows project not attached error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detachProjectStub.rejects(new Error('ProjectNotAttached:0Hn000000000001:0XB000000000001')); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--project-id', + '0Hn000000000001', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('not attached to pipeline'); + }); + }); + + describe('deletion failure', () => { + test + .stdout() + .stderr() + .it('shows failure error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detachProjectStub.resolves({ + success: false, + projectId: '0Hn000000000001', + pipelineId: '0XB000000000001', + error: 'ENTITY_IS_LOCKED', + }); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--project-id', + '0Hn000000000001', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Failed to remove project from pipeline'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detachProjectStub.rejects(new Error("sObject type 'DevopsProjectPipeline' is not supported")); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--project-id', + '0Hn000000000001', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detachProjectStub.rejects(new Error('Network error')); + + try { + await DeleteCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--project-id', + '0Hn000000000001', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); From 3e4f72f894c2cb9963d13fb4fdfb1168c5c2cc52 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Fri, 17 Jul 2026 14:35:02 +0530 Subject: [PATCH 6/9] feat: promotion status --- messages/devops.promotion.status.md | 21 +++ schemas/devops-promotion-status.json | 31 ++++ src/commands/devops/promotion/status.ts | 75 +++++++++ test/commands/devops/promotion/status.test.ts | 151 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 messages/devops.promotion.status.md create mode 100644 schemas/devops-promotion-status.json create mode 100644 src/commands/devops/promotion/status.ts create mode 100644 test/commands/devops/promotion/status.test.ts diff --git a/messages/devops.promotion.status.md b/messages/devops.promotion.status.md new file mode 100644 index 0000000..6289d74 --- /dev/null +++ b/messages/devops.promotion.status.md @@ -0,0 +1,21 @@ +# summary + +Get the status of a promotion request. + +# description + +Returns the current status of a promotion identified by its request token. + +# flags.request-token.summary + +Request token from the promote response. + +# examples + +- Get the status of a promotion: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --request-token a0B000000000001 + +# error.RequestNotFound + +Promotion request %s not found. Check the request token and try again. diff --git a/schemas/devops-promotion-status.json b/schemas/devops-promotion-status.json new file mode 100644 index 0000000..6b91ba1 --- /dev/null +++ b/schemas/devops-promotion-status.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PromotionStatusResult", + "definitions": { + "PromotionStatusResult": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "message": { + "type": ["string", "null"] + }, + "errorDetails": { + "type": ["string", "null"] + }, + "requestToken": { + "type": "string" + }, + "requestCompletionDate": { + "type": ["string", "null"] + } + }, + "required": ["id", "status", "message", "errorDetails", "requestToken", "requestCompletionDate"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/promotion/status.ts b/src/commands/devops/promotion/status.ts new file mode 100644 index 0000000..e55dd95 --- /dev/null +++ b/src/commands/devops/promotion/status.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { getPromotionStatus, PromotionStatusResult } from '../../../utils/getPromotionStatus.js'; +import { colorStatus } from '../../../common/outputService/outputUtils.js'; +import { AsyncOperationStatus } from '../../../common/types.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promotion.status'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPromotionStatus extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'request-token': Flags.string({ + summary: messages.getMessage('flags.request-token.summary'), + required: true, + char: 'i', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPromotionStatus); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const requestToken = flags['request-token']; + + let result: PromotionStatusResult; + try { + result = await getPromotionStatus(connection, requestToken); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + if (errMsg.startsWith('RequestNotFound:') || errMsg.includes('entity is deleted')) { + this.error(messages.getMessage('error.RequestNotFound', [requestToken])); + } + throw error; + } + + const statusStr = Object.values(AsyncOperationStatus).includes(result.status as AsyncOperationStatus) + ? colorStatus(result.status as AsyncOperationStatus) + : result.status; + + this.log(` Request Token: ${result.requestToken}`); + this.log(` ID: ${result.id}`); + this.log(` Status: ${statusStr}`); + if (result.message) this.log(` Message: ${result.message}`); + if (result.errorDetails) this.log(` Error Details: ${result.errorDetails}`); + if (result.requestCompletionDate) this.log(` Completion Date: ${result.requestCompletionDate}`); + + return result; + } +} diff --git a/test/commands/devops/promotion/status.test.ts b/test/commands/devops/promotion/status.test.ts new file mode 100644 index 0000000..4fc2323 --- /dev/null +++ b/test/commands/devops/promotion/status.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2026, Salesforce, 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 esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops promotion status', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let StatusCommand: any; + const getPromotionStatusStub = sinon.stub(); + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/promotion/status.js', { + '../../../../src/utils/getPromotionStatus.js': { + getPromotionStatus: getPromotionStatusStub, + }, + }); + StatusCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getPromotionStatusStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('completed promotion', () => { + test + .stdout() + .stderr() + .it('displays all fields', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPromotionStatusStub.resolves({ + id: '0Bf000000000001', + status: 'Completed', + message: 'Promotion completed successfully', + errorDetails: null, + requestToken: 'a0B000000000001', + requestCompletionDate: '2026-07-17T10:00:00.000Z', + }); + + await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); + + expect(ctx.stdout).to.contain('a0B000000000001'); + expect(ctx.stdout).to.contain('0Bf000000000001'); + expect(ctx.stdout).to.contain('Promotion completed successfully'); + expect(ctx.stdout).to.contain('2026-07-17T10:00:00.000Z'); + }); + }); + + describe('failed promotion', () => { + test + .stdout() + .stderr() + .it('displays error details', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPromotionStatusStub.resolves({ + id: '0Bf000000000002', + status: 'Error', + message: 'Deployment failed', + errorDetails: 'ApexClass MyClass has compile errors', + requestToken: 'a0B000000000002', + requestCompletionDate: null, + }); + + await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000002']); + + expect(ctx.stdout).to.contain('Error'); + expect(ctx.stdout).to.contain('ApexClass MyClass has compile errors'); + }); + }); + + describe('request not found', () => { + test + .stdout() + .stderr() + .it('shows request not found error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPromotionStatusStub.rejects(new Error('RequestNotFound:a0B000000000099')); + + try { + await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000099']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('not found'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPromotionStatusStub.rejects(new Error("sObject type 'DevopsRequestInfo' is not supported")); + + try { + await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPromotionStatusStub.rejects(new Error('Network error')); + + try { + await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); From fe53fa7da95739287dc7143ef6bdf4284d3da8a1 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Wed, 22 Jul 2026 14:21:20 +0530 Subject: [PATCH 7/9] chore: remove promotion status command --- messages/devops.promotion.status.md | 21 --- schemas/devops-promotion-status.json | 31 ---- src/commands/devops/promotion/status.ts | 75 --------- src/utils/getPromotionStatus.ts | 58 ------- test/commands/devops/promotion/status.test.ts | 151 ------------------ 5 files changed, 336 deletions(-) delete mode 100644 messages/devops.promotion.status.md delete mode 100644 schemas/devops-promotion-status.json delete mode 100644 src/commands/devops/promotion/status.ts delete mode 100644 src/utils/getPromotionStatus.ts delete mode 100644 test/commands/devops/promotion/status.test.ts diff --git a/messages/devops.promotion.status.md b/messages/devops.promotion.status.md deleted file mode 100644 index 6289d74..0000000 --- a/messages/devops.promotion.status.md +++ /dev/null @@ -1,21 +0,0 @@ -# summary - -Get the status of a promotion request. - -# description - -Returns the current status of a promotion identified by its request token. - -# flags.request-token.summary - -Request token from the promote response. - -# examples - -- Get the status of a promotion: - - <%= config.bin %> <%= command.id %> --target-org my-devops-org --request-token a0B000000000001 - -# error.RequestNotFound - -Promotion request %s not found. Check the request token and try again. diff --git a/schemas/devops-promotion-status.json b/schemas/devops-promotion-status.json deleted file mode 100644 index 6b91ba1..0000000 --- a/schemas/devops-promotion-status.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/PromotionStatusResult", - "definitions": { - "PromotionStatusResult": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "message": { - "type": ["string", "null"] - }, - "errorDetails": { - "type": ["string", "null"] - }, - "requestToken": { - "type": "string" - }, - "requestCompletionDate": { - "type": ["string", "null"] - } - }, - "required": ["id", "status", "message", "errorDetails", "requestToken", "requestCompletionDate"], - "additionalProperties": false - } - } -} diff --git a/src/commands/devops/promotion/status.ts b/src/commands/devops/promotion/status.ts deleted file mode 100644 index e55dd95..0000000 --- a/src/commands/devops/promotion/status.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026, Salesforce, 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 { Messages, Org } from '@salesforce/core'; -import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { getPromotionStatus, PromotionStatusResult } from '../../../utils/getPromotionStatus.js'; -import { colorStatus } from '../../../common/outputService/outputUtils.js'; -import { AsyncOperationStatus } from '../../../common/types.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promotion.status'); -const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); - -export default class DevopsPromotionStatus extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - - public static readonly flags = { - 'target-org': Flags.requiredOrg(), - 'api-version': Flags.orgApiVersion(), - 'request-token': Flags.string({ - summary: messages.getMessage('flags.request-token.summary'), - required: true, - char: 'i', - }), - }; - - public async run(): Promise { - const { flags } = await this.parse(DevopsPromotionStatus); - const org: Org = flags['target-org']; - const connection = org.getConnection(flags['api-version']); - const requestToken = flags['request-token']; - - let result: PromotionStatusResult; - try { - result = await getPromotionStatus(connection, requestToken); - } catch (error: unknown) { - const errMsg = error instanceof Error ? error.message : String(error); - if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { - this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); - } - if (errMsg.startsWith('RequestNotFound:') || errMsg.includes('entity is deleted')) { - this.error(messages.getMessage('error.RequestNotFound', [requestToken])); - } - throw error; - } - - const statusStr = Object.values(AsyncOperationStatus).includes(result.status as AsyncOperationStatus) - ? colorStatus(result.status as AsyncOperationStatus) - : result.status; - - this.log(` Request Token: ${result.requestToken}`); - this.log(` ID: ${result.id}`); - this.log(` Status: ${statusStr}`); - if (result.message) this.log(` Message: ${result.message}`); - if (result.errorDetails) this.log(` Error Details: ${result.errorDetails}`); - if (result.requestCompletionDate) this.log(` Completion Date: ${result.requestCompletionDate}`); - - return result; - } -} diff --git a/src/utils/getPromotionStatus.ts b/src/utils/getPromotionStatus.ts deleted file mode 100644 index 0b53cc9..0000000 --- a/src/utils/getPromotionStatus.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; -import { escapeSOQL } from './soqlUtils.js'; - -export type PromotionStatusResult = { - id: string; - status: string; - message: string | null; - errorDetails: string | null; - requestToken: string; - requestCompletionDate: string | null; -}; - -type DevopsRequestInfoRecord = { - Id: string; - Status: string; - Message: string | null; - ErrorDetails: string | null; - RequestToken: string; - RequestCompletionDate: string | null; -}; - -export async function getPromotionStatus(connection: Connection, requestToken: string): Promise { - const result = await connection.query( - `SELECT Id, Status, Message, ErrorDetails, RequestToken, RequestCompletionDate FROM DevopsRequestInfo WHERE RequestToken = '${escapeSOQL( - requestToken - )}' LIMIT 1` - ); - - const record = (result.records ?? [])[0]; - if (!record) { - throw new Error(`RequestNotFound:${requestToken}`); - } - - return { - id: record.Id, - status: record.Status, - message: record.Message, - errorDetails: record.ErrorDetails, - requestToken: record.RequestToken, - requestCompletionDate: record.RequestCompletionDate, - }; -} diff --git a/test/commands/devops/promotion/status.test.ts b/test/commands/devops/promotion/status.test.ts deleted file mode 100644 index 4fc2323..0000000 --- a/test/commands/devops/promotion/status.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2026, Salesforce, 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 esmock from 'esmock'; -import { expect, test } from '@oclif/test'; -import sinon from 'sinon'; -import { Org } from '@salesforce/core'; - -describe('devops promotion status', () => { - let sandbox: sinon.SinonSandbox; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let StatusCommand: any; - const getPromotionStatusStub = sinon.stub(); - const mockConnection = { getApiVersion: () => '65.0' }; - const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; - - before(async () => { - const mod = await esmock('../../../../src/commands/devops/promotion/status.js', { - '../../../../src/utils/getPromotionStatus.js': { - getPromotionStatus: getPromotionStatusStub, - }, - }); - StatusCommand = mod.default; - }); - - beforeEach(() => { - sandbox = sinon.createSandbox(); - getPromotionStatusStub.reset(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('completed promotion', () => { - test - .stdout() - .stderr() - .it('displays all fields', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.resolves({ - id: '0Bf000000000001', - status: 'Completed', - message: 'Promotion completed successfully', - errorDetails: null, - requestToken: 'a0B000000000001', - requestCompletionDate: '2026-07-17T10:00:00.000Z', - }); - - await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); - - expect(ctx.stdout).to.contain('a0B000000000001'); - expect(ctx.stdout).to.contain('0Bf000000000001'); - expect(ctx.stdout).to.contain('Promotion completed successfully'); - expect(ctx.stdout).to.contain('2026-07-17T10:00:00.000Z'); - }); - }); - - describe('failed promotion', () => { - test - .stdout() - .stderr() - .it('displays error details', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.resolves({ - id: '0Bf000000000002', - status: 'Error', - message: 'Deployment failed', - errorDetails: 'ApexClass MyClass has compile errors', - requestToken: 'a0B000000000002', - requestCompletionDate: null, - }); - - await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000002']); - - expect(ctx.stdout).to.contain('Error'); - expect(ctx.stdout).to.contain('ApexClass MyClass has compile errors'); - }); - }); - - describe('request not found', () => { - test - .stdout() - .stderr() - .it('shows request not found error', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error('RequestNotFound:a0B000000000099')); - - try { - await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000099']); - } catch (e) { - // expected - } - - expect(ctx.stderr).to.contain('not found'); - }); - }); - - describe('DevOps Center not enabled', () => { - test - .stdout() - .stderr() - .it('shows DevOps Center not enabled error', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error("sObject type 'DevopsRequestInfo' is not supported")); - - try { - await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); - } catch (e) { - // expected - } - - expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); - }); - }); - - describe('rethrows other errors', () => { - test - .stdout() - .stderr() - .it('rethrows non-DevOps errors', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error('Network error')); - - try { - await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); - expect.fail('should have thrown'); - } catch (e: unknown) { - expect((e as Error).message).to.contain('Network error'); - } - }); - }); -}); From 2a47fac796fd727c1ddba58a2faf49843395338a Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Thu, 23 Jul 2026 09:20:56 +0530 Subject: [PATCH 8/9] chore: update snapshot --- command-snapshot.json | 348 ++++---------------- schemas/devops-request-status.json | 4 +- src/commands/devops/request/status.ts | 10 +- src/utils/getRequestStatus.ts | 58 ++++ test/commands/devops/request/status.test.ts | 18 +- 5 files changed, 129 insertions(+), 309 deletions(-) create mode 100644 src/utils/getRequestStatus.ts diff --git a/command-snapshot.json b/command-snapshot.json index 18f57f3..bbbc58b 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -3,12 +3,7 @@ "alias": [], "command": "devops:pipeline:create", "flagAliases": [], - "flagChars": [ - "d", - "n", - "o", - "r" - ], + "flagChars": ["d", "n", "o", "r"], "flags": [ "api-version", "bitbucket-project-key", @@ -29,191 +24,87 @@ "alias": [], "command": "devops:pipeline:get", "flagAliases": [], - "flagChars": [ - "i", - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "pipeline-id", - "target-org" - ], + "flagChars": ["i", "o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:list", "flagAliases": [], - "flagChars": [ - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "target-org" - ], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:project:add", "flagAliases": [], - "flagChars": [ - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "pipeline-id", - "project-id", - "target-org" - ], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "project-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:project:delete", "flagAliases": [], - "flagChars": [ - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "pipeline-id", - "project-id", - "target-org" - ], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "project-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:pipeline:stage:add", "flagAliases": [], - "flagChars": [ - "n", - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "name", - "next-stage-id", - "pipeline-id", - "target-org" - ], + "flagChars": ["n", "o"], + "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], - "command": "devops:pipeline:update", + "command": "devops:pipeline:stage:delete", "flagAliases": [], - "flagChars": [ - "n", - "o" - ], - "flags": [ - "active", - "api-version", - "flags-dir", - "json", - "name", - "pipeline-id", - "target-org" - ], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "stage-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], - "command": "devops:pipeline:stage:delete", + "command": "devops:pipeline:update", "flagAliases": [], - "flagChars": [ - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "pipeline-id", - "stage-id", - "target-org" - ], + "flagChars": ["n", "o"], + "flags": ["active", "api-version", "flags-dir", "json", "name", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:project:create", "flagAliases": [], - "flagChars": [ - "d", - "n", - "o" - ], - "flags": [ - "api-version", - "description", - "flags-dir", - "json", - "name", - "target-org" - ], + "flagChars": ["d", "n", "o"], + "flags": ["api-version", "description", "flags-dir", "json", "name", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:project:list", "flagAliases": [], - "flagChars": [ - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "target-org" - ], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:project:update", "flagAliases": [], - "flagChars": [ - "d", - "i", - "n", - "o" - ], - "flags": [ - "api-version", - "description", - "flags-dir", - "is-active", - "json", - "name", - "project-id", - "target-org" - ], + "flagChars": ["d", "i", "n", "o"], + "flags": ["api-version", "description", "flags-dir", "is-active", "json", "name", "project-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:promote", "flagAliases": [], - "flagChars": [ - "a", - "i", - "l", - "o", - "s", - "t" - ], + "flagChars": ["a", "i", "l", "o", "s", "t"], "flags": [ "api-version", "deploy-all", @@ -232,13 +123,7 @@ "alias": [], "command": "devops:promotion:complete", "flagAliases": [], - "flagChars": [ - "a", - "i", - "l", - "o", - "t" - ], + "flagChars": ["a", "i", "l", "o", "t"], "flags": [ "api-version", "deploy-all", @@ -256,46 +141,23 @@ "alias": [], "command": "devops:request:status", "flagAliases": [], - "flagChars": [ - "i", - "o" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "request-token", - "target-org" - ], + "flagChars": ["i", "o"], + "flags": ["api-version", "flags-dir", "json", "request-token", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:review:create", "flagAliases": [], - "flagChars": [ - "n", - "o", - "w" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "target-org", - "work-item-id", - "work-item-name" - ], + "flagChars": ["n", "o", "w"], + "flags": ["api-version", "flags-dir", "json", "target-org", "work-item-id", "work-item-name"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:stage:branch:add", "flagAliases": [], - "flagChars": [ - "b", - "o" - ], + "flagChars": ["b", "o"], "flags": [ "api-version", "branch-name", @@ -312,10 +174,7 @@ "alias": [], "command": "devops:stage:environment:add", "flagAliases": [], - "flagChars": [ - "e", - "o" - ], + "flagChars": ["e", "o"], "flags": [ "api-version", "environment-name", @@ -331,38 +190,25 @@ }, { "alias": [], - "command": "devops:work-item:combine", + "command": "devops:stage:environment:delete", "flagAliases": [], - "flagChars": [ - "o", - "t" - ], - "flags": [ - "api-version", - "child-work-item-id", - "flags-dir", - "json", - "parent-work-item-id", - "target-org", - "target-stage-id" - ], + "flagChars": ["e", "o"], + "flags": ["api-version", "environment-id", "flags-dir", "json", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], - "command": "devops:stage:environment:delete", + "command": "devops:work-item:combine", "flagAliases": [], - "flagChars": [ - "e", - "o" - ], + "flagChars": ["o", "t"], "flags": [ "api-version", - "environment-id", + "child-work-item-id", "flags-dir", "json", - "pipeline-id", - "target-org" + "parent-work-item-id", + "target-org", + "target-stage-id" ], "plugin": "@salesforce/plugin-devops-center" }, @@ -370,68 +216,31 @@ "alias": [], "command": "devops:work-item:create", "flagAliases": [], - "flagChars": [ - "d", - "o", - "p", - "s" - ], - "flags": [ - "api-version", - "description", - "flags-dir", - "json", - "project-id", - "subject", - "target-org" - ], + "flagChars": ["d", "o", "p", "s"], + "flags": ["api-version", "description", "flags-dir", "json", "project-id", "subject", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:list", "flagAliases": [], - "flagChars": [ - "o", - "p" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "project-id", - "target-org" - ], + "flagChars": ["o", "p"], + "flags": ["api-version", "flags-dir", "json", "project-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:prepare", "flagAliases": [], - "flagChars": [ - "i", - "o", - "t" - ], - "flags": [ - "api-version", - "flags-dir", - "json", - "target-org", - "target-stage-id", - "work-item-id" - ], + "flagChars": ["i", "o", "t"], + "flags": ["api-version", "flags-dir", "json", "target-org", "target-stage-id", "work-item-id"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "devops:work-item:update", "flagAliases": [], - "flagChars": [ - "n", - "o", - "w" - ], + "flagChars": ["n", "o", "w"], "flags": [ "api-version", "description", @@ -449,12 +258,7 @@ "alias": [], "command": "project:deploy:pipeline:quick", "flagAliases": [], - "flagChars": [ - "c", - "i", - "r", - "w" - ], + "flagChars": ["c", "i", "r", "w"], "flags": [ "async", "concise", @@ -472,56 +276,23 @@ "alias": [], "command": "project:deploy:pipeline:report", "flagAliases": [], - "flagChars": [ - "c", - "i", - "r" - ], - "flags": [ - "devops-center-username", - "flags-dir", - "job-id", - "json", - "use-most-recent" - ], + "flagChars": ["c", "i", "r"], + "flags": ["devops-center-username", "flags-dir", "job-id", "json", "use-most-recent"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "project:deploy:pipeline:resume", "flagAliases": [], - "flagChars": [ - "c", - "i", - "r", - "w" - ], - "flags": [ - "concise", - "devops-center-username", - "flags-dir", - "job-id", - "json", - "use-most-recent", - "verbose", - "wait" - ], + "flagChars": ["c", "i", "r", "w"], + "flags": ["concise", "devops-center-username", "flags-dir", "job-id", "json", "use-most-recent", "verbose", "wait"], "plugin": "@salesforce/plugin-devops-center" }, { "alias": [], "command": "project:deploy:pipeline:start", "flagAliases": [], - "flagChars": [ - "a", - "b", - "c", - "l", - "p", - "t", - "v", - "w" - ], + "flagChars": ["a", "b", "c", "l", "p", "t", "v", "w"], "flags": [ "async", "branch-name", @@ -543,16 +314,7 @@ "alias": [], "command": "project:deploy:pipeline:validate", "flagAliases": [], - "flagChars": [ - "a", - "b", - "c", - "l", - "p", - "t", - "v", - "w" - ], + "flagChars": ["a", "b", "c", "l", "p", "t", "v", "w"], "flags": [ "async", "branch-name", @@ -570,4 +332,4 @@ ], "plugin": "@salesforce/plugin-devops-center" } -] \ No newline at end of file +] diff --git a/schemas/devops-request-status.json b/schemas/devops-request-status.json index 6b91ba1..0e1759c 100644 --- a/schemas/devops-request-status.json +++ b/schemas/devops-request-status.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/PromotionStatusResult", + "$ref": "#/definitions/RequestStatusResult", "definitions": { - "PromotionStatusResult": { + "RequestStatusResult": { "type": "object", "properties": { "id": { diff --git a/src/commands/devops/request/status.ts b/src/commands/devops/request/status.ts index 0f240e3..6f26a2a 100644 --- a/src/commands/devops/request/status.ts +++ b/src/commands/devops/request/status.ts @@ -16,7 +16,7 @@ import { Messages, Org } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { getPromotionStatus, PromotionStatusResult } from '../../../utils/getPromotionStatus.js'; +import { getRequestStatus, RequestStatusResult } from '../../../utils/getRequestStatus.js'; import { colorStatus } from '../../../common/outputService/outputUtils.js'; import { AsyncOperationStatus } from '../../../common/types.js'; @@ -24,7 +24,7 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.request.status'); const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); -export default class DevopsRequestStatus extends SfCommand { +export default class DevopsRequestStatus extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -39,15 +39,15 @@ export default class DevopsRequestStatus extends SfCommand { + public async run(): Promise { const { flags } = await this.parse(DevopsRequestStatus); const org: Org = flags['target-org']; const connection = org.getConnection(flags['api-version']); const requestToken = flags['request-token']; - let result: PromotionStatusResult; + let result: RequestStatusResult; try { - result = await getPromotionStatus(connection, requestToken); + result = await getRequestStatus(connection, requestToken); } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { diff --git a/src/utils/getRequestStatus.ts b/src/utils/getRequestStatus.ts new file mode 100644 index 0000000..280cb6c --- /dev/null +++ b/src/utils/getRequestStatus.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026, Salesforce, 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 { Connection } from '@salesforce/core'; +import { escapeSOQL } from './soqlUtils.js'; + +export type RequestStatusResult = { + id: string; + status: string; + message: string | null; + errorDetails: string | null; + requestToken: string; + requestCompletionDate: string | null; +}; + +type DevopsRequestInfoRecord = { + Id: string; + Status: string; + Message: string | null; + ErrorDetails: string | null; + RequestToken: string; + RequestCompletionDate: string | null; +}; + +export async function getRequestStatus(connection: Connection, requestToken: string): Promise { + const result = await connection.query( + `SELECT Id, Status, Message, ErrorDetails, RequestToken, RequestCompletionDate FROM DevopsRequestInfo WHERE RequestToken = '${escapeSOQL( + requestToken + )}' LIMIT 1` + ); + + const record = (result.records ?? [])[0]; + if (!record) { + throw new Error(`RequestNotFound:${requestToken}`); + } + + return { + id: record.Id, + status: record.Status, + message: record.Message, + errorDetails: record.ErrorDetails, + requestToken: record.RequestToken, + requestCompletionDate: record.RequestCompletionDate, + }; +} diff --git a/test/commands/devops/request/status.test.ts b/test/commands/devops/request/status.test.ts index 819dbb8..2878cf1 100644 --- a/test/commands/devops/request/status.test.ts +++ b/test/commands/devops/request/status.test.ts @@ -23,14 +23,14 @@ describe('devops request status', () => { let sandbox: sinon.SinonSandbox; // eslint-disable-next-line @typescript-eslint/no-explicit-any let StatusCommand: any; - const getPromotionStatusStub = sinon.stub(); + const getRequestStatusStub = sinon.stub(); const mockConnection = { getApiVersion: () => '65.0' }; const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; before(async () => { const mod = await esmock('../../../../src/commands/devops/request/status.js', { - '../../../../src/utils/getPromotionStatus.js': { - getPromotionStatus: getPromotionStatusStub, + '../../../../src/utils/getRequestStatus.js': { + getRequestStatus: getRequestStatusStub, }, }); StatusCommand = mod.default; @@ -38,7 +38,7 @@ describe('devops request status', () => { beforeEach(() => { sandbox = sinon.createSandbox(); - getPromotionStatusStub.reset(); + getRequestStatusStub.reset(); }); afterEach(() => { @@ -52,7 +52,7 @@ describe('devops request status', () => { .it('displays all fields', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.resolves({ + getRequestStatusStub.resolves({ id: '0Bf000000000001', status: 'Completed', message: 'Promotion completed successfully', @@ -77,7 +77,7 @@ describe('devops request status', () => { .it('displays error details', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.resolves({ + getRequestStatusStub.resolves({ id: '0Bf000000000002', status: 'Error', message: 'Deployment failed', @@ -100,7 +100,7 @@ describe('devops request status', () => { .it('shows request not found error', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error('RequestNotFound:a0B000000000099')); + getRequestStatusStub.rejects(new Error('RequestNotFound:a0B000000000099')); try { await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000099']); @@ -119,7 +119,7 @@ describe('devops request status', () => { .it('shows DevOps Center not enabled error', async (ctx) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error("sObject type 'DevopsRequestInfo' is not supported")); + getRequestStatusStub.rejects(new Error("sObject type 'DevopsRequestInfo' is not supported")); try { await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); @@ -138,7 +138,7 @@ describe('devops request status', () => { .it('rethrows non-DevOps errors', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); - getPromotionStatusStub.rejects(new Error('Network error')); + getRequestStatusStub.rejects(new Error('Network error')); try { await StatusCommand.run(['--target-org', 'testOrg', '--request-token', 'a0B000000000001']); From c7d372eaa6215d2c563c9cf1043d1f19ccddd66e Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Thu, 23 Jul 2026 11:34:53 +0530 Subject: [PATCH 9/9] fix: add SOQL injection validation to remaining utils Added validateSalesforceId() calls to 5 additional utility functions to prevent SOQL injection vulnerabilities: - prepareWorkItem.ts: validate workItemId in resolveProjectIdFromWorkItem - deleteStageEnvironment.ts: validate environmentId before query - getPipeline.ts: validate pipelineId before query - detachProject.ts: validate both projectId and pipelineId - stage/environment/delete.ts: document existing validation via Flags.salesforceId() All SOQL queries in the codebase now use either validateSalesforceId() for IDs or escapeSOQL() for string values, preventing injection attacks. Co-Authored-By: Claude Sonnet 4.5 --- src/commands/devops/stage/environment/delete.ts | 1 + src/utils/deleteStageEnvironment.ts | 2 ++ src/utils/detachProject.ts | 3 +++ src/utils/getPipeline.ts | 2 ++ src/utils/prepareWorkItem.ts | 6 ++---- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/devops/stage/environment/delete.ts b/src/commands/devops/stage/environment/delete.ts index 5d04888..b5c4e81 100644 --- a/src/commands/devops/stage/environment/delete.ts +++ b/src/commands/devops/stage/environment/delete.ts @@ -49,6 +49,7 @@ export default class DevopsStageEnvironmentDelete extends SfCommand { + validateSalesforceId(environmentId, 'environment'); const stageResult = await connection.query<{ Id: string }>( `SELECT Id FROM DevopsPipelineStage WHERE DevOpsEnvironmentId = '${environmentId}'` ); diff --git a/src/utils/detachProject.ts b/src/utils/detachProject.ts index 354b002..5fcd085 100644 --- a/src/utils/detachProject.ts +++ b/src/utils/detachProject.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; export type DetachProjectResult = { success: boolean; @@ -28,6 +29,8 @@ export async function detachProject( projectId: string, pipelineId: string ): Promise { + validateSalesforceId(projectId, 'project'); + validateSalesforceId(pipelineId, 'pipeline'); const queryResult = await connection.query<{ Id: string }>( `SELECT Id FROM DevopsProjectPipeline WHERE DevopsProjectId = '${projectId}' AND DevopsPipelineId = '${pipelineId}' LIMIT 1` ); diff --git a/src/utils/getPipeline.ts b/src/utils/getPipeline.ts index 1fc9e47..47f1812 100644 --- a/src/utils/getPipeline.ts +++ b/src/utils/getPipeline.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; type DevopsPipelineRecord = { Id: string; @@ -91,6 +92,7 @@ function orderStages(stages: PipelineStageDetail[]): PipelineStageDetail[] { } export async function getPipeline(connection: Connection, pipelineId: string): Promise { + validateSalesforceId(pipelineId, 'pipeline'); const pipelineResult = await connection.query( `SELECT Id, Name, Description, IsActive FROM DevopsPipeline WHERE Id = '${pipelineId}' LIMIT 1` ); diff --git a/src/utils/prepareWorkItem.ts b/src/utils/prepareWorkItem.ts index cfc6584..3c526a1 100644 --- a/src/utils/prepareWorkItem.ts +++ b/src/utils/prepareWorkItem.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; export type PrepareWorkItemParams = { connection: Connection; @@ -77,10 +78,7 @@ export async function resolveProjectIdFromWorkItem( connection: Connection, workItemId: string ): Promise { - // Validate workItemId format before using in SOQL - if (!/^[a-zA-Z0-9]{15,18}$/.test(workItemId)) { - throw new Error('Invalid work item ID format.'); - } + validateSalesforceId(workItemId, 'work item'); const result = await connection.query<{ DevopsProjectId: string; DevopsPipelineStageId: string }>( `SELECT DevopsProjectId, DevopsPipelineStageId FROM WorkItem WHERE Id = '${workItemId}' LIMIT 1` );