diff --git a/command-snapshot.json b/command-snapshot.json index 35ae8a3..bbbc58b 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -20,6 +20,22 @@ ], "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", @@ -28,6 +44,14 @@ "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", @@ -36,6 +60,14 @@ "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "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:pipeline:update", @@ -60,6 +92,14 @@ "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", @@ -148,6 +188,14 @@ ], "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:combine", 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/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/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.project.update.md b/messages/devops.project.update.md new file mode 100644 index 0000000..17bfa06 --- /dev/null +++ b/messages/devops.project.update.md @@ -0,0 +1,45 @@ +# summary + +Update a DevOps Center project. + +# description + +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. + +# flags.is-active.summary + +Set the project active status. Use --is-active to activate or --no-is-active to deactivate. + +# examples + +- 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-id 1Qg000000000001 --description "Updated release description" + +- Deactivate a project: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --project-id 1Qg000000000001 --no-is-active + +- Update all fields at once: + + <%= 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 --name, --description, or --is-active/--no-is-active. 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-get.json b/schemas/devops-pipeline-get.json new file mode 100644 index 0000000..8d4ddcf --- /dev/null +++ b/schemas/devops-pipeline-get.json @@ -0,0 +1,98 @@ +{ + "$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"] + }, + "environment": { + "anyOf": [ + { + "$ref": "#/definitions/StageEnvironment" + }, + { + "type": "null" + } + ] + } + }, + "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": { + "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/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/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-project-update.json b/schemas/devops-project-update.json new file mode 100644 index 0000000..1be6211 --- /dev/null +++ b/schemas/devops-project-update.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/UpdateProjectResult", + "definitions": { + "UpdateProjectResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projectId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + } + } +} 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/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 new file mode 100644 index 0000000..a7a3dc3 --- /dev/null +++ b/src/commands/devops/pipeline/get.ts @@ -0,0 +1,90 @@ +/* + * 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 ?? '', + Environment: s.environment?.name ?? '', + 'Environment ID': s.environment?.id ?? '', + })), + columns: ['ID', 'Name', 'Next Stage ID', 'Branch', 'Repository', 'Environment', 'Environment ID'], + }); + } + + 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/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/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/project/update.ts b/src/commands/devops/project/update.ts new file mode 100644 index 0000000..6dfef53 --- /dev/null +++ b/src/commands/devops/project/update.ts @@ -0,0 +1,90 @@ +/* + * 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 { 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-id': Flags.salesforceId({ + summary: messages.getMessage('flags.project-id.summary'), + char: 'i', + required: true, + }), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + char: 'n', + }), + 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['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 result: UpdateProjectResult; + try { + result = await updateProject({ + connection, + projectId: flags['project-id'], + name: flags['name'], + 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) { + 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 { + this.error(`Failed to update project: ${result.error ?? ''}`); + } + + return result; + } +} 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/commands/devops/stage/environment/delete.ts b/src/commands/devops/stage/environment/delete.ts new file mode 100644 index 0000000..b5c4e81 --- /dev/null +++ b/src/commands/devops/stage/environment/delete.ts @@ -0,0 +1,83 @@ +/* + * 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']; + + // pipeline-id and environment-id are already validated by Flags.salesforceId() + 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..2385695 --- /dev/null +++ b/src/utils/deleteStageEnvironment.ts @@ -0,0 +1,51 @@ +/* + * 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 { validateSalesforceId } from './soqlUtils.js'; + +export type DeleteStageEnvironmentResult = { + success: boolean; + environmentId?: string; + stageId?: string; + error?: string; +}; + +export async function deleteStageEnvironment( + connection: Connection, + environmentId: string +): Promise { + validateSalesforceId(environmentId, 'environment'); + 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/detachProject.ts b/src/utils/detachProject.ts new file mode 100644 index 0000000..5fcd085 --- /dev/null +++ b/src/utils/detachProject.ts @@ -0,0 +1,56 @@ +/* + * 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 { validateSalesforceId } from './soqlUtils.js'; + +export type DetachProjectResult = { + success: boolean; + projectId: string; + pipelineId: string; + error?: string; +}; + +export async function detachProject( + connection: Connection, + 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` + ); + + 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/src/utils/getPipeline.ts b/src/utils/getPipeline.ts new file mode 100644 index 0000000..47f1812 --- /dev/null +++ b/src/utils/getPipeline.ts @@ -0,0 +1,138 @@ +/* + * 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 { validateSalesforceId } from './soqlUtils.js'; + +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; + DevOpsEnvironment: { + Id: string; + Name: string; + } | null; +}; + +type ProjectPipelineRecord = { + DevopsProjectId: string; + DevopsProject: { Name: string } | null; +}; + +export type StageEnvironment = { + id: string; + name: string; +}; + +export type PipelineStageDetail = { + id: string; + name: string | null; + nextStageId: string | null; + branchName: string | null; + repositoryName: string | null; + repositoryOwner: string | null; + environment: StageEnvironment | 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 { + validateSalesforceId(pipelineId, 'pipeline'); + 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, DevOpsEnvironment.Id, DevOpsEnvironment.Name 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, + environment: s.DevOpsEnvironment ? { id: s.DevOpsEnvironment.Id, name: s.DevOpsEnvironment.Name } : 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/getPromotionStatus.ts b/src/utils/getRequestStatus.ts similarity index 91% rename from src/utils/getPromotionStatus.ts rename to src/utils/getRequestStatus.ts index 0b53cc9..280cb6c 100644 --- a/src/utils/getPromotionStatus.ts +++ b/src/utils/getRequestStatus.ts @@ -17,7 +17,7 @@ import { Connection } from '@salesforce/core'; import { escapeSOQL } from './soqlUtils.js'; -export type PromotionStatusResult = { +export type RequestStatusResult = { id: string; status: string; message: string | null; @@ -35,7 +35,7 @@ type DevopsRequestInfoRecord = { RequestCompletionDate: string | null; }; -export async function getPromotionStatus(connection: Connection, requestToken: string): Promise { +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 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/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` ); diff --git a/src/utils/updateProject.ts b/src/utils/updateProject.ts new file mode 100644 index 0000000..85bbf1a --- /dev/null +++ b/src/utils/updateProject.ts @@ -0,0 +1,61 @@ +/* + * 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; + name?: string; + description?: string; + isActive?: boolean; +}; + +export type UpdateProjectResult = { + success: boolean; + projectId?: string; + name?: string; + description?: string; + isActive?: boolean; + error?: string; +}; + +export async function updateProject(params: UpdateProjectParams): Promise { + const { connection, projectId, name, description, isActive } = params; + + 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; + + const result = await connection.sobject('DevopsProject').update(updatePayload); + + if (result.success) { + return { + success: true, + projectId, + name, + 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/pipeline/get.test.ts b/test/commands/devops/pipeline/get.test.ts new file mode 100644 index 0000000..ac21513 --- /dev/null +++ b/test/commands/devops/pipeline/get.test.ts @@ -0,0 +1,173 @@ +/* + * 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', + environment: { id: '0Xe000000000001', name: 'Integration_Org' }, + }, + { + id: 'stage2', + name: 'Production', + nextStageId: null, + branchName: 'main', + repositoryName: 'myrepo', + repositoryOwner: 'myorg', + environment: null, + }, + ], + 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('Integration_Org'); + expect(ctx.stdout).to.contain('0Xe000000000001'); + 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', () => {}); + }); +}); 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'); + } + }); + }); +}); 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/project/update.test.ts b/test/commands/devops/project/update.test.ts new file mode 100644 index 0000000..fbe0aac --- /dev/null +++ b/test/commands/devops/project/update.test.ts @@ -0,0 +1,252 @@ +/* + * 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(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/project/update.js', { + '../../../../src/utils/updateProject.js': { + updateProject: updateProjectStub, + }, + }); + UpdateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + updateProjectStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + 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() + .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 — 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); + updateProjectStub.resolves({ + success: true, + projectId: '1Qg000000000001', + isActive: false, + }); + + await UpdateCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001', '--no-is-active']); + + expect(ctx.stdout).to.contain('Successfully updated project'); + expect(ctx.stdout).to.contain('IsActive: false'); + }); + }); + + describe('successful update — all fields', () => { + test + .stdout() + .stderr() + .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, + }); + + await UpdateCommand.run([ + '--target-org', + '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'); + }); + }); + + describe('no update fields provided', () => { + test + .stdout() + .stderr() + .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); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('--name'); + }); + }); + + 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', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + updateProjectStub.rejects(new Error("sObject type 'DevopsProject' is not supported")); + + try { + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--project-id', + '1Qg000000000001', + '--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'); + } + }); + }); +}); 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']); 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'); + } + }); + }); +});