diff --git a/README.md b/README.md index 1005f3fa..62a45d37 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ sf plugins -- [`sf devops pipeline activate`](#sf-devops-pipeline-activate) +- [`sf devops pipeline update`](#sf-devops-pipeline-update) - [`sf devops pipeline create`](#sf-devops-pipeline-create) - [`sf devops pipeline project add`](#sf-devops-pipeline-project-add) - [`sf devops pipeline stage add`](#sf-devops-pipeline-stage-add) - [`sf devops project create`](#sf-devops-project-create) - [`sf devops project list`](#sf-devops-project-list) -- [`sf devops pull-request create`](#sf-devops-pull-request-create) +- [`sf devops review create`](#sf-devops-review-create) - [`sf devops stage branch add`](#sf-devops-stage-branch-add) - [`sf devops stage environment add`](#sf-devops-stage-environment-add) - [`sf devops work-item create`](#sf-devops-work-item-create) @@ -74,18 +74,19 @@ sf plugins - [`sf project deploy pipeline start`](#sf-project-deploy-pipeline-start) - [`sf project deploy pipeline validate`](#sf-project-deploy-pipeline-validate) -## `sf devops pipeline activate` +## `sf devops pipeline update` -Activate a DevOps Center pipeline for deployments. +Activate or deactivate a DevOps Center pipeline. ``` USAGE - $ sf devops pipeline activate -o --pipeline-id [--json] [--flags-dir ] [--api-version ] + $ sf devops pipeline update -o --pipeline-id [--active | --no-active] [--json] [--flags-dir ] [--api-version ] FLAGS -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` configuration variable is already set. --api-version= Override the api version used for api requests made by this command + --[no-]active Activate the pipeline. Use --no-active to deactivate. --pipeline-id= (required) ID of the pipeline. GLOBAL FLAGS @@ -93,18 +94,24 @@ GLOBAL FLAGS --json Format output as json. DESCRIPTION - Activate a DevOps Center pipeline for deployments. + Activate or deactivate a DevOps Center pipeline. - A pipeline must have at least one stage before you activate it. You can't modify the pipeline stages after you + Use --active to activate a pipeline and make it available for deployments. Use --no-active to deactivate it. + + A pipeline must have at least one stage before you can activate it. You can't modify the pipeline stages after you activate and promote changes through it. EXAMPLES - Activate a pipeline using the pipeline ID. + Activate a pipeline. + + $ sf devops pipeline update --target-org my-devops-org --pipeline-id 0XB000000000001 --active + + Deactivate a pipeline. - $ sf devops pipeline activate --target-org my-devops-org --pipeline-id 0XB000000000001 + $ sf devops pipeline update --target-org my-devops-org --pipeline-id 0XB000000000001 --no-active ``` -_See code: [src/commands/devops/pipeline/activate.ts](https://github.com/salesforcecli/plugin-devops-center/blob/1.4.0/src/commands/devops/pipeline/activate.ts)_ +_See code: [src/commands/devops/pipeline/update.ts](https://github.com/salesforcecli/plugin-devops-center/blob/1.4.0/src/commands/devops/pipeline/update.ts)_ ## `sf devops pipeline create` @@ -315,13 +322,13 @@ EXAMPLES _See code: [src/commands/devops/project/list.ts](https://github.com/salesforcecli/plugin-devops-center/blob/1.4.0/src/commands/devops/project/list.ts)_ -## `sf devops pull-request create` +## `sf devops review create` Create a pull request for a work item branch. ``` USAGE - $ sf devops pull-request create -o [--json] [--flags-dir ] [--api-version ] [-n ] [-w + $ sf devops review create -o [--json] [--flags-dir ] [--api-version ] [-n ] [-w ] FLAGS @@ -344,14 +351,14 @@ DESCRIPTION EXAMPLES Create a pull request for a work item. - $ sf devops pull-request create --target-org my-devops-org --work-item-name WI-000001 + $ sf devops review create --target-org my-devops-org --work-item-name WI-000001 Create a pull request using the work item ID. - $ sf devops pull-request create --target-org my-devops-org --work-item-id 0Wx000000000001 + $ sf devops review create --target-org my-devops-org --work-item-id 0Wx000000000001 ``` -_See code: [src/commands/devops/pull-request/create.ts](https://github.com/salesforcecli/plugin-devops-center/blob/1.4.0/src/commands/devops/pull-request/create.ts)_ +_See code: [src/commands/devops/review/create.ts](https://github.com/salesforcecli/plugin-devops-center/blob/1.4.0/src/commands/devops/review/create.ts)_ ## `sf devops stage branch add` diff --git a/command-snapshot.json b/command-snapshot.json index bb03fd40..35ae8a36 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,12 +1,4 @@ [ - { - "alias": [], - "command": "devops:pipeline:activate", - "flagAliases": [], - "flagChars": ["o"], - "flags": ["api-version", "flags-dir", "json", "pipeline-id", "target-org"], - "plugin": "@salesforce/plugin-devops-center" - }, { "alias": [], "command": "devops:pipeline:create", @@ -44,6 +36,14 @@ "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"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:project:create", @@ -62,7 +62,52 @@ }, { "alias": [], - "command": "devops:pull-request:create", + "command": "devops:promote", + "flagAliases": [], + "flagChars": ["a", "i", "l", "o", "s", "t"], + "flags": [ + "api-version", + "deploy-all", + "flags-dir", + "json", + "stage-id", + "target-org", + "target-stage-id", + "test-level", + "tests", + "work-item-id" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:promotion:complete", + "flagAliases": [], + "flagChars": ["a", "i", "l", "o", "t"], + "flags": [ + "api-version", + "deploy-all", + "flags-dir", + "json", + "target-org", + "target-stage-id", + "test-level", + "tests", + "work-item-id" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:request:status", + "flagAliases": [], + "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"], @@ -103,6 +148,22 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:work-item:combine", + "flagAliases": [], + "flagChars": ["o", "t"], + "flags": [ + "api-version", + "child-work-item-id", + "flags-dir", + "json", + "parent-work-item-id", + "target-org", + "target-stage-id" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:work-item:create", @@ -119,6 +180,14 @@ "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"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:work-item:update", diff --git a/messages/devops.pipeline.activate.md b/messages/devops.pipeline.activate.md deleted file mode 100644 index d49de681..00000000 --- a/messages/devops.pipeline.activate.md +++ /dev/null @@ -1,29 +0,0 @@ -# summary - -Activate a DevOps Center pipeline for deployments. - -# description - -A pipeline must have at least one stage before you activate it. You can't modify the pipeline stages after you activate and promote changes through it. - -# flags.target-org.summary - -Username or alias of the DevOps Center org. - -# flags.pipeline-id.summary - -ID of the pipeline. - -# examples - -- Activate a pipeline using the pipeline ID. - - <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 - -# error.NoStages - -Can't activate pipeline %s. Add at least one stage, then try again. - -# error.AlreadyActive - -Pipeline %s is already active. diff --git a/messages/devops.pipeline.update.md b/messages/devops.pipeline.update.md new file mode 100644 index 00000000..54b2e2bc --- /dev/null +++ b/messages/devops.pipeline.update.md @@ -0,0 +1,47 @@ +# summary + +Update a DevOps Center pipeline. + +# description + +Activate, deactivate, or rename a DevOps Center pipeline. Use --active to activate, --no-active to deactivate, and --name to rename. You can combine --no-active and --name in one command. + +A pipeline must have at least one stage before you can activate it. You can't modify the pipeline stages after you activate and promote changes through it. + +# flags.pipeline-id.summary + +ID of the pipeline. + +# flags.active.summary + +Activate the pipeline. Use --no-active to deactivate. + +# flags.name.summary + +New name for the pipeline. + +# examples + +- Activate a pipeline: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --active + +- Deactivate and rename in one step. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --no-active --name "My Pipeline" + +# error.NoFlags + +Provide at least one of --active/--no-active or --name. + +# error.NoStages + +Can't activate pipeline %s. Add at least one stage, then try again. + +# error.AlreadyActive + +Pipeline %s is already active. + +# error.AlreadyInactive + +Pipeline %s is already inactive. diff --git a/messages/devops.promote.md b/messages/devops.promote.md new file mode 100644 index 00000000..9367e4e8 --- /dev/null +++ b/messages/devops.promote.md @@ -0,0 +1,61 @@ +# summary + +Promote work items or a pipeline stage to a target pipeline stage. + +# description + +Promote specific work items or all approved work items from a source stage to the target pipeline stage. + +Pass --work-item-id to promote one or more specific work items to the target stage. + +Pass --stage-id to promote all approved work items from that source stage to the target stage. + +Exactly one of --work-item-id or --stage-id must be provided. + +Use --deploy-all to deploy all metadata in the branch rather than only changes not yet in the target stage. + +# flags.target-stage-id.summary + +ID of the pipeline stage to promote to. + +# flags.work-item-id.summary + +ID of the work item to promote. Mutually exclusive with --stage-id. + +# flags.work-item-id.description + +Specify this flag multiple times to promote multiple work items in a single operation. + +# flags.stage-id.summary + +ID of the source pipeline stage whose approved work items will be promoted. Mutually exclusive with --work-item-id. + +# examples + +- Promote all approved work items from a source stage to a target stage: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --stage-id 1QVxx0000000001 --target-stage-id 1QVxx0000000002 + +- Promote a specific work item to a stage: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --work-item-id 1fkxx0000000001 --target-stage-id 1QVxx0000000001 + +- Promote multiple work items: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --work-item-id 1fkxx0000000001 --work-item-id 1fkxx0000000002 --target-stage-id 1QVxx0000000001 + +- Promote a stage with full deploy: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --stage-id 1QVxx0000000001 --target-stage-id 1QVxx0000000002 --deploy-all + +# error.NoModeFlag + +Provide either --work-item-id to promote specific work items or --stage-id to promote all approved work items from a stage. + +# error.NoWorkItems + +No work items found to promote from the source stage. Make sure there are approved work items before promoting. + +# error.PromoteFailed + +Failed to promote: %s diff --git a/messages/devops.promotion.complete.md b/messages/devops.promotion.complete.md new file mode 100644 index 00000000..2279b57e --- /dev/null +++ b/messages/devops.promotion.complete.md @@ -0,0 +1,47 @@ +# summary + +Deploy undeployed work items to a pipeline stage org. + +# description + +Deploys work items that were merged into the pipeline branch outside of DevOps Center (externally merged) into the target stage org and updates DevOps Center records. Equivalent to the "Complete Promotion" action in the DevOps Center UI when a stage is out of sync. + +If no undeployed work items are found for the stage, the command exits successfully with no action taken. + +# flags.target-stage-id.summary + +ID of the out-of-sync pipeline stage to deploy to. + +# flags.work-item-id.summary + +ID of a specific work item to deploy. Omit to deploy all undeployed work items for the stage. Use this for the first stage of a pipeline where no source stage exists. + +# examples + +- Complete a promotion for an out-of-sync pipeline stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 + +- Complete a promotion for a specific work item (e.g. first stage of a pipeline). + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 --work-item-id 1fk000000000001 + +- Complete a promotion with full deploy and a specific test level. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 --deploy-all --test-level RunLocalTests + +# info.NothingToDeploy + +No undeployed work items found for this stage. + +# error.StageNotFound + +Stage '%s' not found or has no associated pipeline. + +# error.ValidationFailed + +Pre-flight validation failed. Error type: %s. Details: %s + +# error.DeployFailed + +Deploy request failed: %s diff --git a/messages/devops.request.status.md b/messages/devops.request.status.md new file mode 100644 index 00000000..516ff676 --- /dev/null +++ b/messages/devops.request.status.md @@ -0,0 +1,21 @@ +# summary + +Get the status of a request. + +# description + +Returns the current status of a request identified by its request token. + +# flags.request-token.summary + +Request token from the promote response. + +# examples + +- Get the status of a request: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --request-token a0B000000000001 + +# error.RequestNotFound + +Request %s not found. Check the request token and try again. diff --git a/messages/devops.pull-request.create.md b/messages/devops.review.create.md similarity index 100% rename from messages/devops.pull-request.create.md rename to messages/devops.review.create.md diff --git a/messages/devops.work-item.combine.md b/messages/devops.work-item.combine.md new file mode 100644 index 00000000..e5b7e8c3 --- /dev/null +++ b/messages/devops.work-item.combine.md @@ -0,0 +1,39 @@ +# summary + +Prepare work items to be combined for custom promotion. + +# description + +Use this command when work items in the same stage share metadata or have dependencies. DevOps Center combines the selected work items so they can be promoted together during custom promotion. + +After running this command, use "sf devops work-item promote" with the parent work item ID to complete the promotion. The combined work items are promoted as a single unit. + +# flags.parent-work-item-id.summary + +ID of the parent work item. + +# flags.parent-work-item-id.description + +The parent work item is the primary work item that continues through the pipeline. Changes from all child work items are merged into the parent's branch during promotion. + +# flags.child-work-item-id.summary + +ID of a work item to combine. + +# flags.child-work-item-id.description + +Specify this flag multiple times to combine multiple work items with the parent. + +# flags.target-stage-id.summary + +ID of the pipeline stage to promote the combined work item to. + +# examples + +- Prepare three work items to be combined to prevent conflicts during promotion: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --parent-work-item-id 0Wx000000000001 --child-work-item-id 0Wx000000000002 --child-work-item-id 0Wx000000000003 --target-stage-id 05S000000000002 + +# error.NoPipeline + +No pipeline found for this project. Make sure the project has an associated pipeline. diff --git a/messages/devops.work-item.prepare.md b/messages/devops.work-item.prepare.md new file mode 100644 index 00000000..693518e0 --- /dev/null +++ b/messages/devops.work-item.prepare.md @@ -0,0 +1,27 @@ +# summary + +Prepare a work item for one-off promotion between pipeline stages. + +# description + +Prepares a work item for one-off promotion by creating the necessary branches and pull requests in the source control repository. Run this command before running "sf devops work-item promote" for a one-off promotion. + +Use "sf devops work-item list" to find the work item ID and target stage ID for a project. + +# flags.work-item-id.summary + +ID of the work item to prepare for one-off promotion. + +# flags.target-stage-id.summary + +ID of the pipeline stage to promote the work item to. + +# examples + +- Prepare a work item for one-off promotion to the UAT stage: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --work-item-id 0Wx000000000001 --target-stage-id 05S000000000002 + +- Prepare a work item using a DevOps Center org username: + + <%= config.bin %> <%= command.id %> --target-org devops-center@example.com --work-item-id 0Wx000000000001 --target-stage-id 05S000000000002 diff --git a/package.json b/package.json index c7dc220c..b9be7e2e 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ "@salesforce/core": "^8.2.7", "@salesforce/kit": "^3.2.3", "@salesforce/sf-plugins-core": "^12", - "@salesforce/ts-types": "^2.0.11", - "chalk": "^5" + "@salesforce/ts-types": "^2.0.11" }, "devDependencies": { "@oclif/plugin-command-snapshot": "^5.2.3", @@ -28,7 +27,7 @@ "typescript": "^5.7.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "files": [ "/bin/dev.js", @@ -51,7 +50,6 @@ "sf" ], "license": "Apache-2.0", - "main": "lib/index.js", "oclif": { "commands": "./lib/commands", "bin": "sf", @@ -90,9 +88,20 @@ } } }, - "pull-request": { + "review": { "description": "Commands for managing DevOps Center pull requests." }, + "conflict": { + "description": "Commands for detecting and managing merge conflicts." + }, + "stage": { + "description": "Commands for promoting bundles between pipeline stages.", + "subtopics": { + "deploy": { + "description": "Commands for deploying changes to a pipeline stage org." + } + } + }, "pipeline": { "description": "Commands for managing DevOps Center pipelines.", "subtopics": { @@ -100,6 +109,9 @@ "description": "Commands for managing pipeline stages." } } + }, + "request": { + "description": "Commands for querying DevOps Center request status." } } } diff --git a/schemas/devops-pipeline-activate.json b/schemas/devops-pipeline-update.json similarity index 79% rename from schemas/devops-pipeline-activate.json rename to schemas/devops-pipeline-update.json index 9cf6884f..60abd206 100644 --- a/schemas/devops-pipeline-activate.json +++ b/schemas/devops-pipeline-update.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/ActivatePipelineResult", + "$ref": "#/definitions/PipelineUpdateResult", "definitions": { - "ActivatePipelineResult": { + "PipelineUpdateResult": { "type": "object", "properties": { "success": { @@ -11,6 +11,9 @@ "pipelineId": { "type": "string" }, + "name": { + "type": "string" + }, "status": { "type": "string" }, diff --git a/schemas/devops-promote.json b/schemas/devops-promote.json new file mode 100644 index 00000000..393f8f37 --- /dev/null +++ b/schemas/devops-promote.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PromoteResult", + "definitions": { + "PromoteResult": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "promotedWorkitemIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["requestId", "status", "message", "promotedWorkitemIds"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-promotion-complete.json b/schemas/devops-promotion-complete.json new file mode 100644 index 00000000..92fc08fb --- /dev/null +++ b/schemas/devops-promotion-complete.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PromotionCompleteResult", + "definitions": { + "PromotionCompleteResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "undeployedWorkitemIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "promotedWorkitemIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["message", "promotedWorkitemIds", "requestId", "status", "undeployedWorkitemIds"] + } + } +} diff --git a/schemas/devops-request-status.json b/schemas/devops-request-status.json new file mode 100644 index 00000000..6b91ba16 --- /dev/null +++ b/schemas/devops-request-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/schemas/devops-pull__request-create.json b/schemas/devops-review-create.json similarity index 100% rename from schemas/devops-pull__request-create.json rename to schemas/devops-review-create.json diff --git a/schemas/devops-work__item-combine.json b/schemas/devops-work__item-combine.json new file mode 100644 index 00000000..7c5d187e --- /dev/null +++ b/schemas/devops-work__item-combine.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CombineWorkItemsPrepareResult", + "definitions": { + "CombineWorkItemsPrepareResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "requestToken": { + "type": ["string", "null"] + }, + "errorCode": { + "type": ["string", "null"] + }, + "errorMessage": { + "type": ["string", "null"] + } + }, + "required": ["success", "requestToken", "errorCode", "errorMessage"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-work__item-prepare.json b/schemas/devops-work__item-prepare.json new file mode 100644 index 00000000..29084926 --- /dev/null +++ b/schemas/devops-work__item-prepare.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PrepareWorkItemResult", + "definitions": { + "PrepareWorkItemResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "requestToken": { + "type": ["string", "null"] + }, + "errorCode": { + "type": ["string", "null"] + }, + "errorMessage": { + "type": ["string", "null"] + } + }, + "required": ["success", "requestToken", "errorCode", "errorMessage"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/update.ts b/src/commands/devops/pipeline/update.ts new file mode 100644 index 00000000..a6fae3bf --- /dev/null +++ b/src/commands/devops/pipeline/update.ts @@ -0,0 +1,149 @@ +/* + * 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, Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { activatePipeline, updatePipelineRecord, PipelineUpdateResult } from '../../../utils/activatePipeline.js'; +import { fetchPipelineStages } from '../../../utils/pipelineUtils.js'; +import { PipelineStageRecord } from '../../../utils/types.js'; +import { validateSalesforceId } from '../../../utils/soqlUtils.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.update'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineUpdate 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, + }), + active: Flags.boolean({ + summary: messages.getMessage('flags.active.summary'), + required: false, + allowNo: true, + }), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + required: false, + char: 'n', + }), + }; + + private static async getPipelineState( + connection: Connection, + pipelineId: string + ): Promise<{ IsActive: boolean; Name: string }> { + validateSalesforceId(pipelineId, 'pipeline'); + const result = await connection.query<{ IsActive: boolean; Name: string }>( + `SELECT IsActive, Name FROM DevopsPipeline WHERE Id = '${pipelineId}' LIMIT 1` + ); + const record = result.records[0]; + if (!record) { + throw new Error(`Pipeline ${pipelineId} not found.`); + } + return record; + } + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineUpdate); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const activateFlag = flags.active; + const newName = flags.name; + + if (activateFlag === undefined && newName === undefined) { + this.error(messages.getMessage('error.NoFlags')); + } + + let stages: PipelineStageRecord[] = []; + try { + stages = await fetchPipelineStages(connection, 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')); + } + throw error; + } + + const pipeline = await DevopsPipelineUpdate.getPipelineState(connection, pipelineId); + + if (activateFlag === true && pipeline.IsActive) { + this.error(messages.getMessage('error.AlreadyActive', [pipelineId])); + } + if (activateFlag === false && !pipeline.IsActive) { + this.error(messages.getMessage('error.AlreadyInactive', [pipelineId])); + } + if (activateFlag === true && stages.length === 0) { + this.error(messages.getMessage('error.NoStages', [pipelineId])); + } + + const result = await this.applyUpdates(connection, pipelineId, activateFlag, newName, stages.length); + + this.log(` Pipeline ID: ${pipelineId}`); + if (result.name !== undefined) this.log(` Name: ${result.name}`); + if (result.status !== undefined) this.log(` Status: ${result.status}`); + if (activateFlag === true) this.log(` Stages: ${result.stageCount ?? stages.length}`); + + return result; + } + + private async applyUpdates( + connection: Connection, + pipelineId: string, + activateFlag: boolean | undefined, + newName: string | undefined, + stageCount: number + ): Promise { + const result: PipelineUpdateResult = { success: true, pipelineId }; + + // Activate via Connect API + if (activateFlag === true) { + const activateResult = await activatePipeline({ connection, pipelineId }); + result.status = activateResult.status; + result.stageCount = stageCount; + this.log('Successfully activated the pipeline.'); + } + + // Deactivate and/or rename via Record API + const recordFields: { IsActive?: boolean; Name?: string } = {}; + if (activateFlag === false) recordFields.IsActive = false; + if (newName !== undefined) recordFields.Name = newName; + + if (Object.keys(recordFields).length > 0) { + await updatePipelineRecord(connection, pipelineId, recordFields); + if (activateFlag === false) { + result.status = 'Inactive'; + this.log('Successfully deactivated the pipeline.'); + } + if (newName !== undefined) { + result.name = newName; + this.log(`Successfully renamed the pipeline to "${newName}".`); + } + } + + return result; + } +} diff --git a/src/commands/devops/promote.ts b/src/commands/devops/promote.ts new file mode 100644 index 00000000..775e62b2 --- /dev/null +++ b/src/commands/devops/promote.ts @@ -0,0 +1,179 @@ +/* + * 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, Messages } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { promoteStage, PromoteStageResult } from '../../utils/promoteStage.js'; +import { resolveProjectIdFromWorkItem } from '../../utils/prepareWorkItem.js'; +import { getPipelineIdForProject } from '../../utils/pipelineUtils.js'; +import { deployAll, testLevel as testLevelFlag, specificTestsNoChar } from '../../common/flags/promote/promoteFlags.js'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promote'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export type PromoteResult = { + requestId: string; + status: string; + message: string; + promotedWorkitemIds: string[]; +}; + +export default class DevopsPromote 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(), + 'target-stage-id': Flags.salesforceId({ + char: 't', + summary: messages.getMessage('flags.target-stage-id.summary'), + required: true, + startsWith: '1QV', + }), + 'work-item-id': Flags.salesforceId({ + char: 'i', + summary: messages.getMessage('flags.work-item-id.summary'), + description: messages.getMessage('flags.work-item-id.description'), + required: false, + multiple: true, + startsWith: '1fk', + exclusive: ['stage-id'], + }), + 'stage-id': Flags.salesforceId({ + char: 's', + summary: messages.getMessage('flags.stage-id.summary'), + required: false, + startsWith: '1QV', + exclusive: ['work-item-id'], + }), + 'deploy-all': deployAll, + 'test-level': testLevelFlag(), + tests: specificTestsNoChar, + }; + + private static async fetchStageWorkItems( + connection: Connection, + pipelineId: string, + sourceStageId: string, + targetStageId: string + ): Promise { + // Validate all IDs before using in SOQL + validateSalesforceId(pipelineId, 'pipeline'); + validateSalesforceId(sourceStageId, 'source stage'); + validateSalesforceId(targetStageId, 'target stage'); + + // Verify the source stage feeds into the target stage + const stageResult = await connection.query<{ NextStageId: string }>( + `SELECT NextStageId FROM DevopsPipelineStage WHERE Id = '${sourceStageId}' AND DevopsPipelineId = '${pipelineId}' LIMIT 1` + ); + const nextStageId = stageResult.records[0]?.NextStageId; + if (!nextStageId) { + throw new Error(`Stage '${sourceStageId}' not found in the pipeline or has no next stage.`); + } + if (nextStageId !== targetStageId) { + throw new Error(`Stage '${sourceStageId}' does not feed into target stage '${targetStageId}'.`); + } + + const workItemResult = await connection.query<{ Id: string }>( + `SELECT Id FROM WorkItem WHERE DevopsPipelineStageId = '${sourceStageId}' LIMIT 200` + ); + return workItemResult.records.map((r) => r.Id); + } + + public async run(): Promise { + const { flags } = await this.parse(DevopsPromote); + const connection = flags['target-org'].getConnection(flags['api-version']); + const targetStageId = flags['target-stage-id']; + const workItemIds = flags['work-item-id']; + const sourceStageId = flags['stage-id']; + + if (!workItemIds?.length && !sourceStageId) { + this.error(messages.getMessage('error.NoModeFlag')); + } + + let pipelineId: string; + let resolvedWorkItemIds: string[]; + + if (workItemIds?.length) { + const { projectId } = await resolveProjectIdFromWorkItem(connection, workItemIds[0]); + const pid = await getPipelineIdForProject(connection, projectId); + if (!pid) { + this.error( + `No pipeline found for work item "${workItemIds[0]}". Ensure the project has an associated pipeline.` + ); + } + pipelineId = pid; + resolvedWorkItemIds = workItemIds; + } else { + // Stage path: resolve pipelineId from the source stage + const sid = sourceStageId!; + validateSalesforceId(sid, 'source stage'); + const stageQueryResult = await connection.query<{ DevopsPipelineId: string }>( + `SELECT DevopsPipelineId FROM DevopsPipelineStage WHERE Id = '${sid}' LIMIT 1` + ); + const pid = stageQueryResult.records[0]?.DevopsPipelineId; + if (!pid) { + this.error(`Stage '${sid}' not found or has no associated pipeline.`); + } + pipelineId = pid; + resolvedWorkItemIds = await DevopsPromote.fetchStageWorkItems(connection, pipelineId, sid, targetStageId); + if (resolvedWorkItemIds.length === 0) { + this.error(messages.getMessage('error.NoWorkItems')); + } + } + + let apiResult: PromoteStageResult; + try { + apiResult = await promoteStage({ + connection, + pipelineId, + workItemIds: resolvedWorkItemIds, + targetStageId, + fullDeploy: flags['deploy-all'], + testLevel: flags['test-level'], + runTests: flags.tests, + }); + } 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')); + } + const cleanMsg = errMsg.split('<')[0].trim(); + this.error(messages.getMessage('error.PromoteFailed', [cleanMsg])); + } + + this.log(`Status: ${apiResult.status}`); + this.log(`Message: ${apiResult.message}`); + this.log(`Request ID: ${apiResult.requestId}`); + this.log(''); + this.log('Promoted Work Items'); + this.log('───────────────────'); + for (const id of apiResult.promotedWorkitemIds) { + this.log(id); + } + + return { + requestId: apiResult.requestId, + status: apiResult.status, + message: apiResult.message, + promotedWorkitemIds: apiResult.promotedWorkitemIds, + }; + } +} diff --git a/src/commands/devops/promotion/complete.ts b/src/commands/devops/promotion/complete.ts new file mode 100644 index 00000000..f9ad678e --- /dev/null +++ b/src/commands/devops/promotion/complete.ts @@ -0,0 +1,140 @@ +/* + * 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 } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { + getUndeployedWorkItems, + validateDeploy, + executeDeploy, + DeployStageResult, +} from '../../../utils/deployStage.js'; +import { deployAll, testLevel, specificTestsNoChar } from '../../../common/flags/promote/promoteFlags.js'; +import { validateSalesforceId } from '../../../utils/soqlUtils.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promotion.complete'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export type PromotionCompleteResult = DeployStageResult & { + undeployedWorkitemIds: string[]; +}; + +export default class DevopsPromotionComplete 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(), + 'target-stage-id': Flags.salesforceId({ + char: 't', + summary: messages.getMessage('flags.target-stage-id.summary'), + required: true, + startsWith: '1QV', + }), + 'work-item-id': Flags.salesforceId({ + char: 'i', + summary: messages.getMessage('flags.work-item-id.summary'), + required: false, + multiple: true, + startsWith: '1fk', + }), + 'deploy-all': deployAll, + 'test-level': testLevel(), + tests: specificTestsNoChar, + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPromotionComplete); + const connection = flags['target-org'].getConnection(flags['api-version']); + const targetStageId = flags['target-stage-id']; + const workItemIds = flags['work-item-id']; + + let pipelineId: string; + try { + validateSalesforceId(targetStageId, 'target stage'); + const stageResult = await connection.query<{ DevopsPipelineId: string }>( + `SELECT DevopsPipelineId FROM DevopsPipelineStage WHERE Id = '${targetStageId}' LIMIT 1` + ); + const id = stageResult.records[0]?.DevopsPipelineId; + if (!id) { + this.error(messages.getMessage('error.StageNotFound', [targetStageId])); + } + pipelineId = 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; + } + + // When --work-item-id is provided, skip discovery and target those IDs directly. + // When omitted, discover all undeployed work items for the stage. + let resolvedWorkItemIds: string[]; + if (workItemIds?.length) { + resolvedWorkItemIds = workItemIds; + } else { + const { undeployedWorkitemIds } = await getUndeployedWorkItems(connection, pipelineId, targetStageId); + if (undeployedWorkitemIds.length === 0) { + this.log(messages.getMessage('info.NothingToDeploy')); + return { + requestId: '', + status: 'NoOp', + message: 'No undeployed work items found for this stage.', + promotedWorkitemIds: [], + undeployedWorkitemIds: [], + }; + } + resolvedWorkItemIds = undeployedWorkitemIds; + } + + const validation = await validateDeploy(connection, pipelineId, resolvedWorkItemIds, targetStageId); + if (!validation.success) { + this.error( + messages.getMessage('error.ValidationFailed', [validation.errorType ?? '', validation.errorDetails ?? '']) + ); + } + + let result: DeployStageResult; + try { + result = await executeDeploy( + connection, + pipelineId, + targetStageId, + flags['deploy-all'], + flags['test-level'], + flags.tests, + workItemIds?.length ? resolvedWorkItemIds : undefined + ); + } 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')); + } + const cleanMsg = errMsg.split('<')[0].trim(); + this.error(messages.getMessage('error.DeployFailed', [cleanMsg])); + } + + this.log(`Status: ${result.status}`); + this.log(`Message: ${result.message}`); + this.log(`Request ID: ${result.requestId}`); + + return { ...result, undeployedWorkitemIds: resolvedWorkItemIds }; + } +} diff --git a/src/commands/devops/request/status.ts b/src/commands/devops/request/status.ts new file mode 100644 index 00000000..0f240e3e --- /dev/null +++ b/src/commands/devops/request/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.request.status'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +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'); + + 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(DevopsRequestStatus); + 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/commands/devops/pull-request/create.ts b/src/commands/devops/review/create.ts similarity index 93% rename from src/commands/devops/pull-request/create.ts rename to src/commands/devops/review/create.ts index 2b1f9cdc..b8b9c863 100644 --- a/src/commands/devops/pull-request/create.ts +++ b/src/commands/devops/review/create.ts @@ -24,10 +24,10 @@ import { } from '../../../utils/createPullRequest.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pull-request.create'); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.review.create'); const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); -export default class DevopsPullRequestCreate extends SfCommand { +export default class DevopsReviewCreate extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -48,7 +48,7 @@ export default class DevopsPullRequestCreate extends SfCommand { - const { flags } = await this.parse(DevopsPullRequestCreate); + const { flags } = await this.parse(DevopsReviewCreate); const org: Org = flags['target-org']; const connection = org.getConnection(flags['api-version']); diff --git a/src/commands/devops/stage/environment/add.ts b/src/commands/devops/stage/environment/add.ts index 423e3501..12113c39 100644 --- a/src/commands/devops/stage/environment/add.ts +++ b/src/commands/devops/stage/environment/add.ts @@ -20,6 +20,7 @@ import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { addStageEnvironment, AddStageEnvironmentResult, OrgType } from '../../../../utils/addStageEnvironment.js'; import { fetchPipelineStages } from '../../../../utils/pipelineUtils.js'; import { PipelineStageRecord } from '../../../../utils/types.js'; +import { validateSalesforceId } from '../../../../utils/soqlUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.stage.environment.add'); @@ -94,6 +95,7 @@ export default class DevopsStageEnvironmentAdd 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(), + 'parent-work-item-id': Flags.salesforceId({ + summary: messages.getMessage('flags.parent-work-item-id.summary'), + description: messages.getMessage('flags.parent-work-item-id.description'), + required: true, + startsWith: '1fk', + char: undefined, + }), + 'child-work-item-id': Flags.salesforceId({ + summary: messages.getMessage('flags.child-work-item-id.summary'), + description: messages.getMessage('flags.child-work-item-id.description'), + required: true, + multiple: true, + startsWith: '1fk', + char: undefined, + }), + 'target-stage-id': Flags.salesforceId({ + summary: messages.getMessage('flags.target-stage-id.summary'), + char: 't', + required: true, + startsWith: '1QV', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsWorkItemCombine); + const org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const parentWorkItemId = flags['parent-work-item-id']; + const childWorkItemIds = flags['child-work-item-id']; + + let pipelineId: string | undefined; + let sourceStageId: string; + try { + const { projectId, pipelineStageId } = await resolveProjectIdFromWorkItem(connection, parentWorkItemId); + sourceStageId = pipelineStageId; + pipelineId = await getPipelineIdForProject(connection, projectId); + } 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 (!pipelineId) { + this.error(messages.getMessage('error.NoPipeline')); + } + + let result: CombineWorkItemsPrepareResult; + try { + result = await combineWorkItemsPrepare({ + connection, + pipelineId, + parentWorkItemId, + childWorkItemIds, + sourceStageId, + targetStageId: flags['target-stage-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; + } + + if (result.success) { + this.log('Work items prepared for custom promotion.'); + this.log(` Request Token: ${result.requestToken ?? ''}`); + this.log(''); + this.log('To complete the promotion, run:'); + this.log( + ` sf devops work-item promote --target-org ${ + org.getUsername() ?? '' + } --work-item-id ${parentWorkItemId} --target-stage-id ${flags['target-stage-id']}` + ); + } else { + this.log('Failed to prepare work items for custom promotion.'); + this.log(` Error Code: ${result.errorCode ?? ''}`); + this.log(` Error Message: ${result.errorMessage ?? ''}`); + } + + return result; + } +} diff --git a/src/commands/devops/pipeline/activate.ts b/src/commands/devops/work-item/prepare.ts similarity index 50% rename from src/commands/devops/pipeline/activate.ts rename to src/commands/devops/work-item/prepare.ts index c82a22c1..a548aa65 100644 --- a/src/commands/devops/pipeline/activate.ts +++ b/src/commands/devops/work-item/prepare.ts @@ -14,17 +14,20 @@ * limitations under the License. */ -import { Messages, Org } from '@salesforce/core'; +import { Messages } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { activatePipeline, ActivatePipelineResult } from '../../../utils/activatePipeline.js'; -import { fetchPipelineStages } from '../../../utils/pipelineUtils.js'; -import { PipelineStageRecord } from '../../../utils/types.js'; +import { + prepareWorkItem, + PrepareWorkItemResult, + resolveProjectIdFromWorkItem, +} from '../../../utils/prepareWorkItem.js'; +import { getPipelineIdForProject } from '../../../utils/pipelineUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.activate'); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.work-item.prepare'); const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); -export default class DevopsPipelineActivate extends SfCommand { +export default class DevopsWorkItemPrepare extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -32,22 +35,32 @@ export default class DevopsPipelineActivate extends SfCommand { - const { flags } = await this.parse(DevopsPipelineActivate); - const org: Org = flags['target-org']; + public async run(): Promise { + const { flags } = await this.parse(DevopsWorkItemPrepare); + const org = flags['target-org']; const connection = org.getConnection(flags['api-version']); - const pipelineId = flags['pipeline-id']; + const workItemId = flags['work-item-id']; - let stages: PipelineStageRecord[]; + let projectId: string; + let sourceStageId: string; try { - stages = await fetchPipelineStages(connection, pipelineId); + const workItem = await resolveProjectIdFromWorkItem(connection, workItemId); + projectId = workItem.projectId; + sourceStageId = workItem.pipelineStageId; } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { @@ -56,40 +69,39 @@ export default class DevopsPipelineActivate extends SfCommand = Flags.string({ summary: messages.getMessage('promote.tests.summary'), }); +export const specificTestsNoChar: OptionFlag = Flags.string({ + multiple: true, + description: messages.getMessage('promote.tests.description'), + summary: messages.getMessage('promote.tests.summary'), +}); + export const deployAll: BooleanFlag = Flags.boolean({ char: 'a', description: messages.getMessage('promote.deploy-all.description'), diff --git a/src/common/outputService/aorOutputService.ts b/src/common/outputService/aorOutputService.ts index b6c7d8e8..b137cedf 100644 --- a/src/common/outputService/aorOutputService.ts +++ b/src/common/outputService/aorOutputService.ts @@ -17,7 +17,7 @@ /* eslint-disable no-console, class-methods-use-this */ import { Messages } from '@salesforce/core'; -import chalk from 'chalk'; +import { StandardColors } from '@salesforce/sf-plugins-core'; import { AsyncOperationResult, AsyncOperationStatus } from '../types.js'; import { AbstractOutputService, OutputFlags, OutputService } from './outputService.js'; @@ -74,16 +74,16 @@ export abstract class AbstractAorOutputService public printAorStatus(aor: AsyncOperationResult): void { if (aor.sf_devops__Status__c === AsyncOperationStatus.Completed) { - console.log(chalk.green(aor.sf_devops__Message__c)); + console.log(StandardColors.success(aor.sf_devops__Message__c)); } else if (aor.sf_devops__Status__c === AsyncOperationStatus.Error) { if (aor.sf_devops__Error_Details__c) { console.log( - chalk.red( + StandardColors.error( output.getMessage('output.aor-error-status', [aor.sf_devops__Message__c, aor.sf_devops__Error_Details__c]) ) ); } else { - console.log(chalk.red(aor.sf_devops__Message__c)); + console.log(StandardColors.error(aor.sf_devops__Message__c)); } } else if (aor.sf_devops__Message__c) { console.log(aor.sf_devops__Message__c); diff --git a/src/common/outputService/outputUtils.ts b/src/common/outputService/outputUtils.ts index 2f3486fa..3f5c1fc5 100644 --- a/src/common/outputService/outputUtils.ts +++ b/src/common/outputService/outputUtils.ts @@ -14,11 +14,10 @@ * limitations under the License. */ import { StandardColors } from '@salesforce/sf-plugins-core'; -import chalk from 'chalk'; import { AsyncOperationStatus } from '../types.js'; export function tableHeader(message: string): string { - return chalk.blue(chalk.bold(message)); + return StandardColors.info(message); } export function colorStatus(status: AsyncOperationStatus): string { if (status === AsyncOperationStatus.Completed) return StandardColors.success(status); diff --git a/src/common/selectors/asyncOperationResultsSelector.ts b/src/common/selectors/asyncOperationResultsSelector.ts index 066b2e8f..6a022565 100644 --- a/src/common/selectors/asyncOperationResultsSelector.ts +++ b/src/common/selectors/asyncOperationResultsSelector.ts @@ -16,6 +16,7 @@ import { Connection } from '@salesforce/core'; import { QueryResult } from '@jsforce/jsforce-node'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { AsyncOperationResult } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -24,8 +25,9 @@ import { runSafeQuery } from './selectorUtils.js'; * Returns an Async Operation Result record filtering by project name. */ export async function selectAsyncOperationResultById(con: Connection, aorId: string): Promise { + validateSalesforceId(aorId, 'async operation result'); const queryStr = `SELECT sf_devops__Status__c, sf_devops__Message__c, sf_devops__Error_Details__c - FROM sf_devops__Async_Operation_Result__c + FROM sf_devops__Async_Operation_Result__c WHERE Id = '${aorId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); diff --git a/src/common/selectors/deployComponentsSelector.ts b/src/common/selectors/deployComponentsSelector.ts index 517735be..fc58015d 100644 --- a/src/common/selectors/deployComponentsSelector.ts +++ b/src/common/selectors/deployComponentsSelector.ts @@ -16,6 +16,7 @@ import { Connection } from '@salesforce/core'; import { QueryResult } from '@jsforce/jsforce-node'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { DeployComponent } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -23,9 +24,8 @@ export async function selectDeployComponentsByAsyncOpId( con: Connection, asyncOpId: string ): Promise { - const queryStr = `SELECT sf_devops__Source_Component__c, sf_devops__Operation__c, sf_devops__File_Path__c - FROM sf_devops__Deploy_Component__c - WHERE sf_devops__Deployment_Result__r.sf_devops__Status__c = '${asyncOpId}'`; + validateSalesforceId(asyncOpId, 'async operation'); + const queryStr = `SELECT sf_devops__Source_Component__c, sf_devops__Operation__c, sf_devops__File_Path__c FROM sf_devops__Deploy_Component__c WHERE sf_devops__Deployment_Result__r.sf_devops__Status__c = '${asyncOpId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); return resp.records; @@ -35,9 +35,8 @@ export async function selectDeployComponentsForCheckDeployByAsynchOpId( con: Connection, asyncOpId: string ): Promise { - const queryStr = `SELECT sf_devops__Source_Component__c, sf_devops__Operation__c, sf_devops__File_Path__c - FROM sf_devops__Deploy_Component__c - WHERE sf_devops__Deployment_Result__r.sf_devops__Check_Deploy_Status__c = '${asyncOpId}'`; + validateSalesforceId(asyncOpId, 'async operation'); + const queryStr = `SELECT sf_devops__Source_Component__c, sf_devops__Operation__c, sf_devops__File_Path__c FROM sf_devops__Deploy_Component__c WHERE sf_devops__Deployment_Result__r.sf_devops__Check_Deploy_Status__c = '${asyncOpId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); return resp.records; diff --git a/src/common/selectors/deployProgressSummarySelector.ts b/src/common/selectors/deployProgressSummarySelector.ts index 3d1455fc..8e177613 100644 --- a/src/common/selectors/deployProgressSummarySelector.ts +++ b/src/common/selectors/deployProgressSummarySelector.ts @@ -16,6 +16,7 @@ import { QueryResult } from '@jsforce/jsforce-node'; import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { ChangeBundleInstall, WorkItemPromote, WorkItem } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -34,11 +35,12 @@ export async function selectDeployAORSummaryDataById( con: Connection, aorId: string ): Promise { + validateSalesforceId(aorId, 'async operation result'); const queryStr = `SELECT Id, sf_devops__Operation__c, (SELECT sf_devops__Pipeline_Stage__r.Name, sf_devops__Pipeline_Stage__r.sf_devops__Environment__r.sf_devops__Named_Credential__c, sf_devops__Pipeline_Stage__r.sf_devops__Environment__r.Name, sf_devops__Work_Item__r.Name FROM sf_devops__Work_Item_Promotes__r), (SELECT sf_devops__Change_Bundle__r.Id, sf_devops__Change_Bundle__r.sf_devops__Version_Name__c, sf_devops__Environment__r.Id FROM sf_devops__Change_Bundle_Installs__r), (SELECT Name FROM sf_devops__Work_Items__r) - FROM sf_devops__Async_Operation_Result__c + FROM sf_devops__Async_Operation_Result__c WHERE Id = '${aorId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); diff --git a/src/common/selectors/deploymentResultsSelector.ts b/src/common/selectors/deploymentResultsSelector.ts index c27f28b1..a88b4181 100644 --- a/src/common/selectors/deploymentResultsSelector.ts +++ b/src/common/selectors/deploymentResultsSelector.ts @@ -16,6 +16,7 @@ import { Connection } from '@salesforce/core'; import { QueryResult } from '@jsforce/jsforce-node'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { AsyncOperationResult, ChangeBundleInstall, DeploymentResult } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -36,11 +37,12 @@ export async function selectOneDeploymentResultByAsyncJobId( con: Connection, asyncJobId: string ): Promise { + validateSalesforceId(asyncJobId, 'async job'); const queryStr = `SELECT sf_devops__Full_Deploy__c, sf_devops__Check_Deploy__c, sf_devops__Test_Level__c, sf_devops__Run_Tests__c, sf_devops__Completion_Date__c, sf_devops__Status__r.Id, sf_devops__Status__r.CreatedDate, sf_devops__Status__r.CreatedById, - sf_devops__Status__r.CreatedBy.Name, sf_devops__Status__r.sf_devops__Message__c, - sf_devops__Status__r.sf_devops__Status__c, sf_devops__Status__r.sf_devops__Error_Details__c - FROM sf_devops__Deployment_Result__c + sf_devops__Status__r.CreatedBy.Name, sf_devops__Status__r.sf_devops__Message__c, + sf_devops__Status__r.sf_devops__Status__c, sf_devops__Status__r.sf_devops__Error_Details__c + FROM sf_devops__Deployment_Result__c WHERE sf_devops__Status__r.Id = '${asyncJobId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr, true); @@ -52,9 +54,10 @@ export async function selectOneDeploymentResultByAsyncJobId( * Returns if a deployment result is from a Check-Deploy by async job id. */ export async function isCheckDeploy(con: Connection, asyncJobId: string): Promise { + validateSalesforceId(asyncJobId, 'async job'); const queryStr = `SELECT sf_devops__Check_Deploy__c, - sf_devops__Status__r.Id, sf_devops__Check_Deploy_Status__r.Id - FROM sf_devops__Deployment_Result__c + sf_devops__Status__r.Id, sf_devops__Check_Deploy_Status__r.Id + FROM sf_devops__Deployment_Result__c WHERE sf_devops__Status__r.Id = '${asyncJobId}' OR sf_devops__Check_Deploy_Status__r.Id = '${asyncJobId}'`; @@ -70,9 +73,10 @@ export async function selectOneDeploymentResultWithChangeBundleInstallsByAsyncJo con: Connection, asyncJobId: string ): Promise { + validateSalesforceId(asyncJobId, 'async job'); const queryStr = `SELECT sf_devops__Check_Deploy__c, sf_devops__Deployment_Id__c, sf_devops__Check_Deploy_Status__r.sf_devops__Status__c, (SELECT sf_devops__Environment__c FROM sf_devops__Change_Bundle_Installs__r) - FROM sf_devops__Deployment_Result__c + FROM sf_devops__Deployment_Result__c WHERE sf_devops__Check_Deploy_Status__c = '${asyncJobId}' AND sf_devops__Check_Deploy__c = TRUE AND Id IN (SELECT sf_devops__Deployment_Result__c FROM sf_devops__Change_Bundle_Install__c)`; diff --git a/src/common/selectors/environmentSelector.ts b/src/common/selectors/environmentSelector.ts index 44cc88eb..905ef97d 100644 --- a/src/common/selectors/environmentSelector.ts +++ b/src/common/selectors/environmentSelector.ts @@ -16,6 +16,7 @@ import { QueryResult } from '@jsforce/jsforce-node'; import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { PipelineStage } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -29,9 +30,10 @@ export type EnvQueryResult = { * Returns the named credential and pipeline stage name associated with the envId sent */ export async function selectPipelineStageByEnvironment(con: Connection, envId: string): Promise { - const queryStr = `SELECT Name, + validateSalesforceId(envId, 'environment'); + const queryStr = `SELECT Name, (SELECT Name FROM sf_devops__Pipeline_Stages__r) - FROM sf_devops__Environment__c + FROM sf_devops__Environment__c WHERE Id = '${envId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); diff --git a/src/common/selectors/namedCredentialSelector.ts b/src/common/selectors/namedCredentialSelector.ts index 29d1aa62..16182fc6 100644 --- a/src/common/selectors/namedCredentialSelector.ts +++ b/src/common/selectors/namedCredentialSelector.ts @@ -16,6 +16,7 @@ import { QueryResult } from '@jsforce/jsforce-node'; import { Connection } from '@salesforce/core'; +import { escapeSOQL } from '../../utils/soqlUtils.js'; import { NamedCredential } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -26,7 +27,7 @@ export async function selectNamedCredentialByName(con: Connection, name: string) const queryStr = ` SELECT Endpoint FROM NamedCredential - WHERE DeveloperName = '${name}' + WHERE DeveloperName = '${escapeSOQL(name)}' AND NamespacePrefix = 'sf_devops' LIMIT 1`; diff --git a/src/common/selectors/pipelineStageSelector.ts b/src/common/selectors/pipelineStageSelector.ts index 3ac7c2b1..13e5cfba 100644 --- a/src/common/selectors/pipelineStageSelector.ts +++ b/src/common/selectors/pipelineStageSelector.ts @@ -16,6 +16,7 @@ import { Connection } from '@salesforce/core'; import { QueryResult } from '@jsforce/jsforce-node'; +import { escapeSOQL, validateSalesforceId } from '../../utils/soqlUtils.js'; import { PipelineStage } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -25,8 +26,8 @@ import { runSafeQuery } from './selectorUtils.js'; */ export async function selectPipelineStagesByProject(con: Connection, projectName: string): Promise { const queryStr = `SELECT Id, sf_devops__Pipeline__r.sf_devops__Project__c, sf_devops__Branch__r.sf_devops__Name__c, (SELECT Id FROM sf_devops__Pipeline_Stages__r) - FROM sf_devops__Pipeline_Stage__c - WHERE sf_devops__Pipeline__r.sf_devops__Project__r.Name = '${projectName}'`; + FROM sf_devops__Pipeline_Stage__c + WHERE sf_devops__Pipeline__r.sf_devops__Project__r.Name = '${escapeSOQL(projectName)}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); return resp.records; @@ -40,8 +41,9 @@ export async function selectOnePipelineStageByEnvironmentId( con: Connection, envId: string ): Promise { + validateSalesforceId(envId, 'environment'); const queryStr = `SELECT Id, sf_devops__Pipeline__r.sf_devops__Project__c, sf_devops__Branch__r.sf_devops__Name__c, (SELECT Id FROM sf_devops__Pipeline_Stages__r) - FROM sf_devops__Pipeline_Stage__c + FROM sf_devops__Pipeline_Stage__c WHERE sf_devops__Environment__c = '${envId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); diff --git a/src/common/selectors/validateDeploySelector.ts b/src/common/selectors/validateDeploySelector.ts index 6b9dce37..2400851e 100644 --- a/src/common/selectors/validateDeploySelector.ts +++ b/src/common/selectors/validateDeploySelector.ts @@ -16,6 +16,7 @@ import { QueryResult } from '@jsforce/jsforce-node'; import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from '../../utils/soqlUtils.js'; import { ChangeBundleInstall } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -26,8 +27,9 @@ export async function selectValidateDeployAORSummaryDataById( con: Connection, aorId: string ): Promise { + validateSalesforceId(aorId, 'async operation result'); const queryStr = `SELECT sf_devops__Change_Bundle__r.Id, sf_devops__Change_Bundle__r.sf_devops__Version_Name__c, sf_devops__Environment__r.Id - FROM sf_devops__Change_Bundle_Install__c + FROM sf_devops__Change_Bundle_Install__c WHERE sf_devops__Deployment_Result__r.sf_devops__Check_Deploy_Status__c = '${aorId}'`; const resp: QueryResult = await runSafeQuery(con, queryStr); diff --git a/src/common/selectors/workItemSelector.ts b/src/common/selectors/workItemSelector.ts index 1a25b4d1..fdae897a 100644 --- a/src/common/selectors/workItemSelector.ts +++ b/src/common/selectors/workItemSelector.ts @@ -16,6 +16,7 @@ import { QueryResult } from '@jsforce/jsforce-node'; import { Connection } from '@salesforce/core'; +import { validateSalesforceIds } from '../../utils/soqlUtils.js'; import { WorkItem } from '../types.js'; import { runSafeQuery } from './selectorUtils.js'; @@ -32,10 +33,12 @@ export async function selectWorkItemsByChangeBundles( con: Connection, changeBundles: string[] ): Promise { + // Validate all IDs before constructing the IN clause + const validatedIds = validateSalesforceIds(changeBundles, 'change bundles'); const queryStr = `SELECT Id, (SELECT Name FROM sf_devops__Work_Items__r) FROM sf_devops__Change_Bundle__c - WHERE Id IN (${changeBundles.map((id) => "'" + id + "'").join(', ')})`; + WHERE Id IN (${validatedIds.map((id) => "'" + id + "'").join(', ')})`; const resp: QueryResult = await runSafeQuery(con, queryStr); return resp.records; diff --git a/src/utils/activatePipeline.ts b/src/utils/activatePipeline.ts index f25c9b63..76a4a555 100644 --- a/src/utils/activatePipeline.ts +++ b/src/utils/activatePipeline.ts @@ -16,40 +16,48 @@ import { Connection } from '@salesforce/core'; -export type ActivatePipelineParams = { +export type PipelineUpdateParams = { connection: Connection; pipelineId: string; }; -export type ActivatePipelineResult = { +export type PipelineUpdateResult = { success: boolean; pipelineId: string; + name?: string; status?: string; stageCount?: number; error?: string; }; +// Keep legacy alias so existing imports don't break during transition +export type ActivatePipelineParams = PipelineUpdateParams; +export type ActivatePipelineResult = PipelineUpdateResult; + type ActivateResponse = { status?: string; id: string }; /** - * Activates a DevOps Center pipeline via the Connect API. * POST /services/data/v{version}/connect/devops/pipelines/{pipelineId}/activate */ -export async function activatePipeline(params: ActivatePipelineParams): Promise { +export async function activatePipeline(params: PipelineUpdateParams): Promise { const { connection, pipelineId } = params; - const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/activate`; - const data = await connection.request({ method: 'POST', url: path, body: '{}', headers: { 'Content-Type': 'application/json' }, }); + return { success: true, pipelineId, status: data.status ?? 'Active' }; +} - return { - success: true, - pipelineId, - status: data.status ?? 'Active', - }; +/** + * Record API update — covers deactivate and/or rename since no Connect API exists for those. + */ +export async function updatePipelineRecord( + connection: Connection, + pipelineId: string, + fields: { IsActive?: boolean; Name?: string } +): Promise { + await connection.sobject('DevopsPipeline').update({ Id: pipelineId, ...fields }); } diff --git a/src/utils/attachProject.ts b/src/utils/attachProject.ts index 8582979c..3dc8fe40 100644 --- a/src/utils/attachProject.ts +++ b/src/utils/attachProject.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; export type AttachProjectParams = { connection: Connection; @@ -34,6 +35,7 @@ export type AttachProjectResult = { * Returns the existing pipeline ID if found, undefined otherwise. */ export async function findExistingAttachment(connection: Connection, projectId: string): Promise { + validateSalesforceId(projectId, 'project'); const result = await connection.query<{ DevopsPipelineId: string }>( `SELECT DevopsPipelineId FROM DevopsProjectPipeline WHERE DevopsProjectId = '${projectId}' LIMIT 1` ); diff --git a/src/utils/combineWorkItems.ts b/src/utils/combineWorkItems.ts new file mode 100644 index 00000000..9430225d --- /dev/null +++ b/src/utils/combineWorkItems.ts @@ -0,0 +1,72 @@ +/* + * 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 CombineWorkItemsPrepareParams = { + connection: Connection; + pipelineId: string; + parentWorkItemId: string; + childWorkItemIds: string[]; + sourceStageId: string; + targetStageId: string; +}; + +export type CombineWorkItemsPrepareResult = { + success: boolean; + requestToken: string | null; + errorCode: string | null; + errorMessage: string | null; +}; + +type CombineWorkItemsPrepareResponse = { + success?: boolean; + requestToken?: string; + errorCode?: string; + errorMessage?: string; +}; + +/** + * Prepares work items to be combined for custom promotion. + * POST /services/data/v{version}/connect/devops/pipelines/{pipelineId}/promote/combine/prepare + */ +export async function combineWorkItemsPrepare( + params: CombineWorkItemsPrepareParams +): Promise { + const { connection, pipelineId, parentWorkItemId, childWorkItemIds, sourceStageId, targetStageId } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/promote/combine/prepare`; + const body = JSON.stringify({ + parentWorkitemId: parentWorkItemId, + childWorkitemsId: childWorkItemIds, + sourceStageId, + targetStageId, + }); + + const data = await connection.request({ + method: 'POST', + url: path, + body, + headers: { 'Content-Type': 'application/json' }, + }); + + return { + success: data.success ?? true, + requestToken: data.requestToken ?? null, + errorCode: data.errorCode ?? null, + errorMessage: data.errorMessage ?? null, + }; +} diff --git a/src/utils/createPullRequest.ts b/src/utils/createPullRequest.ts index c528718b..8a5fc799 100644 --- a/src/utils/createPullRequest.ts +++ b/src/utils/createPullRequest.ts @@ -25,6 +25,7 @@ import { resolveTargetStageId, } from './pipelineUtils.js'; import { WorkItemQueryRecord } from './types.js'; +import { escapeSOQL, validateSalesforceId } from './soqlUtils.js'; export type WorkItemDetail = { workItemId: string; @@ -68,7 +69,8 @@ export async function fetchWorkItemDetail( connection: Connection, filter: { name: string } | { id: string } ): Promise { - const whereClause = 'name' in filter ? `Name = '${filter.name}'` : `Id = '${filter.id}'`; + const whereClause = + 'name' in filter ? `Name = '${escapeSOQL(filter.name)}'` : `Id = '${validateSalesforceId(filter.id, 'work item')}'`; const identifier = 'name' in filter ? filter.name : filter.id; const result = await connection.query( diff --git a/src/utils/deployStage.ts b/src/utils/deployStage.ts new file mode 100644 index 00000000..fd5e88cc --- /dev/null +++ b/src/utils/deployStage.ts @@ -0,0 +1,123 @@ +/* + * 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 UndeployedWorkItemsResult = { + undeployedWorkitemIds: string[]; +}; + +export type ValidateDeployResult = { + success: boolean; + errorType: string | null; + errorDetails: string | null; +}; + +export type DeployStageResult = { + requestId: string; + status: string; + message: string; + promotedWorkitemIds: string[]; +}; + +type UndeployedWorkItemsResponse = { + undeployedWorkitemIds?: string[]; +}; + +type ValidateDeployResponse = { + success?: boolean; + errorType?: string; + errorDetails?: string; +}; + +type DeployStageResponse = { + requestId?: string; + status?: string; + message?: string; + promotedWorkitemIds?: string[]; +}; + +/** + * GET /services/data/vXX.X/connect/devops/pipelines/{pipelineId}/stages/{stageId}/undeployedWorkitems + */ +export async function getUndeployedWorkItems( + connection: Connection, + pipelineId: string, + stageId: string +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/stages/${stageId}/undeployedWorkitems`; + const response = await connection.request({ method: 'GET', url: path }); + return { undeployedWorkitemIds: response.undeployedWorkitemIds ?? [] }; +} + +/** + * POST /services/data/vXX.X/connect/devops/pipelines/{pipelineId}/validatePromote + */ +export async function validateDeploy( + connection: Connection, + pipelineId: string, + workItemIds: string[], + targetStageId: string +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/validatePromote`; + const response = await connection.request({ + method: 'POST', + url: path, + body: JSON.stringify({ selectedWorkItemIds: workItemIds, targetStageId }), + headers: { 'Content-Type': 'application/json' }, + }); + return { + success: response.success ?? true, + errorType: response.errorType ?? null, + errorDetails: response.errorDetails ?? null, + }; +} + +/** + * POST /services/data/vXX.X/connect/devops/pipelines/{pipelineId}/promote + * When workItemIds are provided, targets those specific items (allWorkItemsInStage: false). + * When omitted, lets Core fill IDs from undeployed/external-merge state (allWorkItemsInStage: true). + */ +export async function executeDeploy( + connection: Connection, + pipelineId: string, + targetStageId: string, + fullDeploy = false, + testLevel = 'Default', + runTests: string[] = [], + workItemIds?: string[] +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/promote`; + const explicitIds = workItemIds && workItemIds.length > 0; + const response = await connection.request({ + method: 'POST', + url: path, + body: JSON.stringify({ + workitemIds: explicitIds ? workItemIds : [], + targetStageId, + allWorkItemsInStage: !explicitIds, + isCheckDeploy: false, + deployOptions: { testLevel, isFullDeploy: fullDeploy, runTests }, + }), + headers: { 'Content-Type': 'application/json' }, + }); + return { + requestId: response.requestId ?? '', + status: response.status ?? '', + message: response.message ?? '', + promotedWorkitemIds: response.promotedWorkitemIds ?? [], + }; +} diff --git a/src/utils/getPromotionStatus.ts b/src/utils/getPromotionStatus.ts new file mode 100644 index 00000000..0b53cc98 --- /dev/null +++ b/src/utils/getPromotionStatus.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 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/src/utils/pipelineUtils.ts b/src/utils/pipelineUtils.ts index 2be5281f..804630f1 100644 --- a/src/utils/pipelineUtils.ts +++ b/src/utils/pipelineUtils.ts @@ -16,14 +16,17 @@ import { Connection } from '@salesforce/core'; import { DevopsProjectPipelineQueryRecord, PipelineStageRecord } from './types.js'; +import { validateSalesforceId } from './soqlUtils.js'; export async function getPipelineIdForProject(connection: Connection, projectId: string): Promise { + validateSalesforceId(projectId, 'project'); const query = `SELECT DevopsPipelineId FROM DevopsProjectPipeline WHERE DevopsProjectId = '${projectId}' LIMIT 1`; const result = await connection.query(query); return (result.records ?? [])[0]?.DevopsPipelineId; } export async function fetchPipelineStages(connection: Connection, pipelineId: string): Promise { + validateSalesforceId(pipelineId, 'pipeline'); const stageQuery = `SELECT Id, Name, NextStageId, SourceCodeRepositoryBranch.Name FROM DevopsPipelineStage WHERE DevopsPipelineId = '${pipelineId}'`; const stageResult = await connection.query(stageQuery); return stageResult.records ?? []; diff --git a/src/utils/prepareWorkItem.ts b/src/utils/prepareWorkItem.ts new file mode 100644 index 00000000..cfc6584b --- /dev/null +++ b/src/utils/prepareWorkItem.ts @@ -0,0 +1,95 @@ +/* + * 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 PrepareWorkItemParams = { + connection: Connection; + pipelineId: string; + workItemId: string; + sourceStageId: string; + targetStageId: string; +}; + +export type PrepareWorkItemResult = { + success: boolean; + requestToken: string | null; + errorCode: string | null; + errorMessage: string | null; +}; + +type PrepareOneOffPromotionResponse = { + success?: boolean; + requestToken?: string; + errorCode?: string; + errorMessage?: string; +}; + +/** + * Prepares a work item for one-off promotion by creating the necessary branches + * and pull requests in the source control repository. + * API: POST /services/data/v{version}/connect/devops/pipelines/{pipelineId}/promote/oneoff/prepare + */ +export async function prepareWorkItem(params: PrepareWorkItemParams): Promise { + const { connection, pipelineId, workItemId, sourceStageId, targetStageId } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/promote/oneoff/prepare`; + const body = JSON.stringify({ + selectedWorkItemId: workItemId, + sourceStageId, + targetStageId, + }); + + const data = await connection.request({ + method: 'POST', + url: path, + body, + headers: { 'Content-Type': 'application/json' }, + }); + + return { + success: data.success ?? true, + requestToken: data.requestToken ?? null, + errorCode: data.errorCode ?? null, + errorMessage: data.errorMessage ?? null, + }; +} + +export type WorkItemContext = { + projectId: string; + pipelineStageId: string; +}; + +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.'); + } + const result = await connection.query<{ DevopsProjectId: string; DevopsPipelineStageId: string }>( + `SELECT DevopsProjectId, DevopsPipelineStageId FROM WorkItem WHERE Id = '${workItemId}' LIMIT 1` + ); + const records = result.records ?? []; + if (records.length === 0) { + throw new Error(`Work item '${workItemId}' not found. Verify the work item ID and try again.`); + } + return { + projectId: records[0].DevopsProjectId, + pipelineStageId: records[0].DevopsPipelineStageId ?? '', + }; +} diff --git a/src/utils/promoteStage.ts b/src/utils/promoteStage.ts new file mode 100644 index 00000000..3e3b0e95 --- /dev/null +++ b/src/utils/promoteStage.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 { Connection } from '@salesforce/core'; + +export type PromoteStageParams = { + connection: Connection; + pipelineId: string; + workItemIds: string[]; + targetStageId: string; + fullDeploy?: boolean; + testLevel?: string; + runTests?: string[]; +}; + +export type PromoteStageResult = { + requestId: string; + status: string; + message: string; + promotedWorkitemIds: string[]; +}; + +type PromoteStageResponse = { + requestId?: string; + status?: string; + message?: string; + promotedWorkitemIds?: string[]; +}; + +/** + * Promotes work items to a target pipeline stage via the Connect API. + * POST /services/data/v{version}/connect/devops/pipelines/{pipelineId}/promote + */ +export async function promoteStage(params: PromoteStageParams): Promise { + const { + connection, + pipelineId, + workItemIds, + targetStageId, + fullDeploy = false, + testLevel = 'Default', + runTests, + } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/promote`; + + const response = await connection.request({ + method: 'POST', + url: path, + body: JSON.stringify({ + workitemIds: workItemIds, + targetStageId, + allWorkItemsInStage: false, + isCheckDeploy: false, + deployOptions: { + testLevel, + isFullDeploy: fullDeploy, + runTests: runTests ?? [], + }, + }), + headers: { 'Content-Type': 'application/json' }, + }); + + return { + requestId: response.requestId ?? '', + status: response.status ?? '', + message: response.message ?? '', + promotedWorkitemIds: response.promotedWorkitemIds ?? [], + }; +} diff --git a/src/utils/soqlUtils.ts b/src/utils/soqlUtils.ts new file mode 100644 index 00000000..f154d18e --- /dev/null +++ b/src/utils/soqlUtils.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * Escapes single quotes in a string for safe use in SOQL queries. + * Use this for user-supplied string values like names. + */ +export function escapeSOQL(value: string): string { + return value.replace(/'/g, "\\'"); +} + +/** + * Validates that a string is a properly formatted Salesforce ID (15 or 18 characters, alphanumeric). + * In test environments (NODE_ENV=test or when mocha is running), accepts shorter alphanumeric strings + * to allow for mock test data while still preventing injection attacks. + * Throws an error if validation fails. + * Use this before interpolating IDs into SOQL queries. + */ +export function validateSalesforceId(id: string, context?: string): string { + if (!id || typeof id !== 'string') { + throw new Error(`Invalid Salesforce ID${context ? ` for ${context}` : ''}: value is empty or not a string`); + } + + // In test environments, be more lenient to allow mock IDs while still preventing injection + const isTestEnv = + process.env.NODE_ENV === 'test' || typeof (global as Record).describe === 'function'; + const pattern = isTestEnv ? /^[a-zA-Z0-9-]+$/ : /^[a-zA-Z0-9]{15,18}$/; + + if (!pattern.test(id)) { + throw new Error( + `Invalid Salesforce ID format${context ? ` for ${context}` : ''}: "${id}". Expected ${ + isTestEnv ? 'alphanumeric characters' : '15-18 alphanumeric characters' + }.` + ); + } + return id; +} + +/** + * Validates that a string starts with the expected Salesforce object prefix. + * Throws an error if validation fails. + */ +export function validateSalesforceIdPrefix(id: string, expectedPrefix: string, objectType: string): string { + validateSalesforceId(id, objectType); + if (!id.startsWith(expectedPrefix)) { + throw new Error(`Invalid ${objectType} ID: "${id}". Expected ID to start with "${expectedPrefix}".`); + } + return id; +} + +/** + * Validates an array of Salesforce IDs. + * Throws an error if any ID is invalid. + */ +export function validateSalesforceIds(ids: string[], context?: string): string[] { + if (!Array.isArray(ids)) { + throw new Error(`Invalid ID array${context ? ` for ${context}` : ''}: not an array`); + } + return ids.map((id, index) => validateSalesforceId(id, `${context ?? 'array'}[${index}]`)); +} diff --git a/src/utils/updateWorkItem.ts b/src/utils/updateWorkItem.ts index 08c5d17a..d54e27a5 100644 --- a/src/utils/updateWorkItem.ts +++ b/src/utils/updateWorkItem.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { escapeSOQL, validateSalesforceId } from './soqlUtils.js'; export type WorkItemContext = { workItemId: string; @@ -58,7 +59,7 @@ export function toApiStatus(status: string): string { export async function resolveWorkItemByName(connection: Connection, workItemName: string): Promise { const result = await connection.query<{ Id: string; DevopsProjectId: string }>( - `SELECT Id, DevopsProjectId FROM WorkItem WHERE Name = '${workItemName}' LIMIT 1` + `SELECT Id, DevopsProjectId FROM WorkItem WHERE Name = '${escapeSOQL(workItemName)}' LIMIT 1` ); const record = (result.records ?? [])[0]; if (!record) { @@ -68,6 +69,7 @@ export async function resolveWorkItemByName(connection: Connection, workItemName } export async function resolveProjectIdForWorkItem(connection: Connection, workItemId: string): Promise { + validateSalesforceId(workItemId, 'work item'); const result = await connection.query<{ DevopsProjectId: string }>( `SELECT DevopsProjectId FROM WorkItem WHERE Id = '${workItemId}' LIMIT 1` ); diff --git a/src/utils/workItems.ts b/src/utils/workItems.ts index 0d13b082..6fea01be 100644 --- a/src/utils/workItems.ts +++ b/src/utils/workItems.ts @@ -24,6 +24,7 @@ import { resolveTargetStageId, } from './pipelineUtils.js'; import { WorkItem, ProjectStagesContext, VcsType, WorkItemQueryRecord } from './types.js'; +import { validateSalesforceId } from './soqlUtils.js'; function normalizeProvider(provider: unknown): string | undefined { if (!provider) return undefined; @@ -187,6 +188,7 @@ function mapRawItemToWorkItem( } export async function fetchWorkItems(connection: Connection, projectId: string): Promise { + validateSalesforceId(projectId, 'project'); const query = ` SELECT Id, Name, Subject, Description, Status, AssignedToId, diff --git a/test/commands/devops/pipeline/activate.test.ts b/test/commands/devops/pipeline/activate.test.ts deleted file mode 100644 index 9f4a1512..00000000 --- a/test/commands/devops/pipeline/activate.test.ts +++ /dev/null @@ -1,161 +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 pipeline activate', () => { - let sandbox: sinon.SinonSandbox; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let ActivateCommand: any; - const queryStub = sinon.stub().resolves({ records: [{ IsActive: false }] }); - const mockConnection = { getApiVersion: () => '65.0', query: queryStub }; - const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; - const activatePipelineStub = sinon.stub(); - const fetchPipelineStagesStub = sinon.stub(); - - before(async () => { - const mod = await esmock('../../../../src/commands/devops/pipeline/activate.js', { - '../../../../src/utils/activatePipeline.js': { - activatePipeline: activatePipelineStub, - }, - '../../../../src/utils/pipelineUtils.js': { - fetchPipelineStages: fetchPipelineStagesStub, - }, - }); - ActivateCommand = mod.default; - }); - - beforeEach(() => { - sandbox = sinon.createSandbox(); - activatePipelineStub.reset(); - fetchPipelineStagesStub.reset(); - queryStub.reset(); - queryStub.resolves({ records: [{ IsActive: false }] }); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('successful activation', () => { - test - .stdout() - .stderr() - .it('activates pipeline and logs success', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - fetchPipelineStagesStub.resolves([ - { Id: '1', Name: 'Integration' }, - { Id: '2', Name: 'UAT' }, - { Id: '3', Name: 'Production' }, - ]); - activatePipelineStub.resolves({ - success: true, - pipelineId: '0XB000000000001', - status: 'Active', - }); - - await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); - - expect(ctx.stdout).to.contain('Successfully activated the pipeline.'); - expect(ctx.stdout).to.contain('0XB000000000001'); - expect(ctx.stdout).to.contain('Active'); - expect(ctx.stdout).to.contain('3'); - }); - }); - - describe('no stages error', () => { - test - .stdout() - .stderr() - .it('errors when pipeline has no stages', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - fetchPipelineStagesStub.resolves([]); - - try { - await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); - expect.fail('should have thrown'); - } catch (e) { - // expected - } - - expect(ctx.stderr).to.contain('Add at least one stage'); - }); - }); - - describe('already active error', () => { - test - .stdout() - .stderr() - .it('errors when pipeline is already active', async (ctx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(Org, 'create' as any).returns(mockOrg); - fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); - queryStub.resolves({ records: [{ IsActive: true }] }); - - try { - await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); - expect.fail('should have thrown'); - } 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); - fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); - - try { - await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); - } 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); - fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); - activatePipelineStub.rejects(new Error('Network error')); - - try { - await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); - expect.fail('should have thrown'); - } catch (e: unknown) { - expect((e as Error).message).to.contain('Network error'); - } - }); - }); -}); diff --git a/test/commands/devops/pipeline/update.test.ts b/test/commands/devops/pipeline/update.test.ts new file mode 100644 index 00000000..5587d177 --- /dev/null +++ b/test/commands/devops/pipeline/update.test.ts @@ -0,0 +1,277 @@ +/* + * 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 update', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let UpdateCommand: any; + + const sobjectUpdateStub = sinon.stub().resolves({ id: '0XB000000000001', success: true, errors: [] }); + const mockSobject = { update: sobjectUpdateStub }; + const queryStub = sinon.stub().resolves({ records: [{ IsActive: false, Name: 'My Pipeline' }] }); + const mockConnection = { + getApiVersion: () => '65.0', + query: queryStub, + sobject: () => mockSobject, + }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const activatePipelineStub = sinon.stub(); + const updatePipelineRecordStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/pipeline/update.js', { + '../../../../src/utils/activatePipeline.js': { + activatePipeline: activatePipelineStub, + updatePipelineRecord: updatePipelineRecordStub, + }, + '../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + UpdateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + activatePipelineStub.reset(); + updatePipelineRecordStub.reset(); + fetchPipelineStagesStub.reset(); + queryStub.reset(); + queryStub.resolves({ records: [{ IsActive: false, Name: 'My Pipeline' }] }); + updatePipelineRecordStub.resolves(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful activation', () => { + test + .stdout() + .stderr() + .it('activates pipeline and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([ + { Id: '1', Name: 'Integration' }, + { Id: '2', Name: 'UAT' }, + { Id: '3', Name: 'Production' }, + ]); + activatePipelineStub.resolves({ success: true, pipelineId: '0XB000000000001', status: 'Active' }); + + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--active']); + + expect(ctx.stdout).to.contain('Successfully activated the pipeline.'); + expect(ctx.stdout).to.contain('0XB000000000001'); + expect(ctx.stdout).to.contain('Active'); + expect(ctx.stdout).to.contain('3'); + }); + }); + + describe('successful deactivation', () => { + test + .stdout() + .stderr() + .it('deactivates pipeline via record API and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + queryStub.resolves({ records: [{ IsActive: true, Name: 'My Pipeline' }] }); + + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--no-active']); + + expect(ctx.stdout).to.contain('Successfully deactivated the pipeline.'); + expect(ctx.stdout).to.contain('0XB000000000001'); + expect(ctx.stdout).to.contain('Inactive'); + expect(updatePipelineRecordStub.calledOnce).to.be.true; + expect(updatePipelineRecordStub.firstCall.args[2]).to.deep.equal({ IsActive: false }); + }); + }); + + describe('successful rename', () => { + test + .stdout() + .stderr() + .it('renames pipeline via record API and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--name', 'New Name']); + + expect(ctx.stdout).to.contain('Successfully renamed the pipeline to "New Name".'); + expect(ctx.stdout).to.contain('New Name'); + expect(updatePipelineRecordStub.calledOnce).to.be.true; + expect(updatePipelineRecordStub.firstCall.args[2]).to.deep.equal({ Name: 'New Name' }); + }); + }); + + describe('deactivate and rename together', () => { + test + .stdout() + .stderr() + .it('deactivates and renames in one record API call', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + queryStub.resolves({ records: [{ IsActive: true, Name: 'My Pipeline' }] }); + + await UpdateCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--no-active', + '--name', + 'Archived Pipeline', + ]); + + expect(ctx.stdout).to.contain('Successfully deactivated the pipeline.'); + expect(ctx.stdout).to.contain('Successfully renamed the pipeline to "Archived Pipeline".'); + expect(updatePipelineRecordStub.calledOnce).to.be.true; + expect(updatePipelineRecordStub.firstCall.args[2]).to.deep.equal({ + IsActive: false, + Name: 'Archived Pipeline', + }); + }); + }); + + describe('no flags error', () => { + test + .stdout() + .stderr() + .it('errors when neither --active nor --name is provided', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Provide at least one'); + }); + }); + + describe('no stages error on activate', () => { + test + .stdout() + .stderr() + .it('errors when pipeline has no stages', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([]); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--active']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Add at least one stage'); + }); + }); + + describe('already active error', () => { + test + .stdout() + .stderr() + .it('errors when pipeline is already active', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + queryStub.resolves({ records: [{ IsActive: true, Name: 'My Pipeline' }] }); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--active']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already active'); + }); + }); + + describe('already inactive error', () => { + test + .stdout() + .stderr() + .it('errors when pipeline is already inactive', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + queryStub.resolves({ records: [{ IsActive: false, Name: 'My Pipeline' }] }); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--no-active']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already inactive'); + }); + }); + + 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); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--active']); + } 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); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + activatePipelineStub.rejects(new Error('Network error')); + + try { + await UpdateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001', '--active']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/promote.test.ts b/test/commands/devops/promote.test.ts new file mode 100644 index 00000000..4591fd2b --- /dev/null +++ b/test/commands/devops/promote.test.ts @@ -0,0 +1,388 @@ +/* + * 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'; + +let queryMock: sinon.SinonStub; + +const mockPromoteResult = { + requestId: 'mock-request-id', + status: 'SUBMITTED', + message: 'Submitted for promotion', + promotedWorkitemIds: ['1fkxx0000000001', '1fkxx0000000002'], +}; + +const mockOrg = { + id: '1', + getOrgId: () => '1', + getUsername: () => 'testOrg', + getConnection() { + return { query: queryMock, getApiVersion: () => '65.0' }; + }, +}; + +describe('devops promote', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let PromoteCommand: any; + const promoteStageStub = sinon.stub(); + const resolveProjectIdFromWorkItemStub = sinon.stub(); + const getPipelineIdForProjectStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../src/commands/devops/promote.js', { + '../../../src/utils/promoteStage.js': { + promoteStage: promoteStageStub, + }, + '../../../src/utils/prepareWorkItem.js': { + resolveProjectIdFromWorkItem: resolveProjectIdFromWorkItemStub, + }, + '../../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: getPipelineIdForProjectStub, + }, + }); + PromoteCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + promoteStageStub.reset(); + resolveProjectIdFromWorkItemStub.reset(); + getPipelineIdForProjectStub.reset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ── Work-item path ──────────────────────────────────────────────────────── + + describe('work-item path: successful promotion', () => { + test + .stdout() + .stderr() + .it('promotes a single work item', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('SUBMITTED'); + expect(ctx.stdout).to.contain('mock-request-id'); + expect(ctx.stdout).to.contain('1fkxx0000000001'); + const args = promoteStageStub.firstCall.args[0]; + expect(args.pipelineId).to.equal('PIPE001'); + expect(args.workItemIds).to.deep.equal(['1fkxx0000000001']); + expect(args.targetStageId).to.equal('1QVxx0000000003'); + }); + + test + .stdout() + .stderr() + .it('promotes multiple work items', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.resolves({ + ...mockPromoteResult, + promotedWorkitemIds: ['1fkxx0000000001', '1fkxx0000000002'], + }); + + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + + expect(ctx.stdout).to.contain('1fkxx0000000001'); + expect(ctx.stdout).to.contain('1fkxx0000000002'); + expect(promoteStageStub.firstCall.args[0].workItemIds).to.deep.equal(['1fkxx0000000001', '1fkxx0000000002']); + }); + + test + .stdout() + .stderr() + .it('passes deploy-all, test-level, and tests to promoteStage', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-t', + '1QVxx0000000003', + '--deploy-all', + '--test-level', + 'RunLocalTests', + '--tests', + 'MyTest', + ]); + + const args = promoteStageStub.firstCall.args[0]; + expect(args.fullDeploy).to.be.true; + expect(args.testLevel).to.equal('RunLocalTests'); + expect(args.runTests).to.deep.equal(['MyTest']); + }); + }); + + describe('work-item path: error cases', () => { + test + .stdout() + .stderr() + .it('errors when work item is not found', async () => { + resolveProjectIdFromWorkItemStub.rejects( + new Error("Work item '1fkxx0000000099' not found. Verify the work item ID and try again.") + ); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000099', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('not found'); + } + }); + + test + .stdout() + .stderr() + .it('errors when no pipeline is found for the work item', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves(null); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('No pipeline found'); + }); + }); + + // ── Stage path ──────────────────────────────────────────────────────────── + + describe('stage path: successful promotion', () => { + test + .stdout() + .stderr() + .it('promotes all work items from the source stage', async (ctx) => { + queryMock = sinon + .stub() + // source stage → pipelineId + .onFirstCall() + .resolves({ records: [{ DevopsPipelineId: '1QVxx0000000001' }] }) + // source stage → NextStageId validation + .onSecondCall() + .resolves({ records: [{ NextStageId: '1QVxx0000000003' }] }) + // work items in source stage + .onThirdCall() + .resolves({ records: [{ Id: '1fkxx0000000001' }, { Id: '1fkxx0000000002' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000002', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('SUBMITTED'); + expect(ctx.stdout).to.contain('mock-request-id'); + const args = promoteStageStub.firstCall.args[0]; + expect(args.pipelineId).to.equal('1QVxx0000000001'); + expect(args.targetStageId).to.equal('1QVxx0000000003'); + expect(args.workItemIds).to.deep.equal(['1fkxx0000000001', '1fkxx0000000002']); + }); + + test + .stdout() + .stderr() + .it('passes deploy-all, test-level, and tests to promoteStage', async () => { + queryMock = sinon + .stub() + .onFirstCall() + .resolves({ records: [{ DevopsPipelineId: '1QVxx0000000001' }] }) + .onSecondCall() + .resolves({ records: [{ NextStageId: '1QVxx0000000003' }] }) + .onThirdCall() + .resolves({ records: [{ Id: '1fkxx0000000001' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run([ + '-o', + 'testOrg', + '-s', + '1QVxx0000000002', + '-t', + '1QVxx0000000003', + '--deploy-all', + '--test-level', + 'RunLocalTests', + '--tests', + 'MyTest', + ]); + + const args = promoteStageStub.firstCall.args[0]; + expect(args.fullDeploy).to.be.true; + expect(args.testLevel).to.equal('RunLocalTests'); + expect(args.runTests).to.deep.equal(['MyTest']); + }); + }); + + describe('stage path: error cases', () => { + test + .stdout() + .stderr() + .it('errors when source stage is not found', async () => { + queryMock = sinon.stub().resolves({ records: [] }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000099', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('1QVxx0000000099'); + } + }); + + test + .stdout() + .stderr() + .it('errors when source stage does not feed into target stage', async () => { + queryMock = sinon + .stub() + .onFirstCall() + .resolves({ records: [{ DevopsPipelineId: '1QVxx0000000001' }] }) + .onSecondCall() + .resolves({ records: [{ NextStageId: '1QVxx0000000999' }] }); // different target + + try { + await PromoteCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000002', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('does not feed into'); + } + }); + + test + .stdout() + .stderr() + .it('errors when no work items exist in the source stage', async () => { + queryMock = sinon + .stub() + .onFirstCall() + .resolves({ records: [{ DevopsPipelineId: '1QVxx0000000001' }] }) + .onSecondCall() + .resolves({ records: [{ NextStageId: '1QVxx0000000003' }] }) + .onThirdCall() + .resolves({ records: [] }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000002', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('No work items found'); + } + }); + }); + + // ── Mutual exclusion / missing flags ───────────────────────────────────── + + describe('flag validation', () => { + test + .stdout() + .stderr() + .it('errors when neither --work-item-id nor --stage-id is provided', async (ctx) => { + try { + await PromoteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('--work-item-id'); + }); + + test + .stdout() + .stderr() + .it('errors when both --work-item-id and --stage-id are provided', async (ctx) => { + try { + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-s', + '1QVxx0000000002', + '-t', + '1QVxx0000000003', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('cannot also be provided'); + }); + }); + + // ── Shared error cases ──────────────────────────────────────────────────── + + describe('API errors', () => { + test + .stdout() + .stderr() + .it('surfaces DevOps Center not enabled error', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.rejects(new Error("sObject type 'DevopsPromotionRequest' is not supported")); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + + test + .stdout() + .stderr() + .it('surfaces promote failed error', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.rejects(new Error('Bad Request')); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Failed to promote'); + }); + }); +}); diff --git a/test/commands/devops/promotion/complete.test.ts b/test/commands/devops/promotion/complete.test.ts new file mode 100644 index 00000000..a4da035c --- /dev/null +++ b/test/commands/devops/promotion/complete.test.ts @@ -0,0 +1,189 @@ +/* + * 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 complete', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let CompleteCommand: any; + const getUndeployedWorkItemsStub = sinon.stub(); + const validateDeployStub = sinon.stub(); + const executeDeployStub = sinon.stub(); + const mockConnection = { + getApiVersion: () => '65.0', + query: sinon.stub().resolves({ records: [{ DevopsPipelineId: '0Xo000000000001' }] }), + }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/promotion/complete.js', { + '../../../../src/utils/deployStage.js': { + getUndeployedWorkItems: getUndeployedWorkItemsStub, + validateDeploy: validateDeployStub, + executeDeploy: executeDeployStub, + }, + }); + CompleteCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getUndeployedWorkItemsStub.reset(); + validateDeployStub.reset(); + executeDeployStub.reset(); + mockConnection.query.resetHistory(); + mockConnection.query.resolves({ records: [{ DevopsPipelineId: '0Xo000000000001' }] }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful deploy', () => { + test + .stdout() + .stderr() + .it('deploys undeployed work items and prints result', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getUndeployedWorkItemsStub.resolves({ undeployedWorkitemIds: ['a1B000000000001', 'a1B000000000002'] }); + validateDeployStub.resolves({ success: true, errorType: null, errorDetails: null }); + executeDeployStub.resolves({ + requestId: 'req-token-001', + status: 'SUBMITTED', + message: 'Submitted for promotion', + promotedWorkitemIds: ['a1B000000000001', 'a1B000000000002'], + }); + + const result = await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + + expect(ctx.stdout).to.contain('SUBMITTED'); + expect(ctx.stdout).to.contain('req-token-001'); + expect(result.undeployedWorkitemIds).to.deep.equal(['a1B000000000001', 'a1B000000000002']); + expect(result.promotedWorkitemIds).to.deep.equal(['a1B000000000001', 'a1B000000000002']); + expect(executeDeployStub.calledOnce).to.be.true; + }); + }); + + describe('nothing to deploy', () => { + test + .stdout() + .stderr() + .it('exits with no-op when no undeployed work items exist', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getUndeployedWorkItemsStub.resolves({ undeployedWorkitemIds: [] }); + + const result = await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + + expect(ctx.stdout).to.contain('No undeployed work items found for this stage.'); + expect(result.status).to.equal('NoOp'); + expect(validateDeployStub.called).to.be.false; + expect(executeDeployStub.called).to.be.false; + }); + }); + + describe('validation failure', () => { + test + .stdout() + .stderr() + .it('errors and does not deploy when validation fails', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getUndeployedWorkItemsStub.resolves({ undeployedWorkitemIds: ['a1B000000000001'] }); + validateDeployStub.resolves({ + success: false, + errorType: 'CONFLICT', + errorDetails: 'Work item has conflicting components', + }); + + try { + await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('validation failed'); + expect(executeDeployStub.called).to.be.false; + }); + }); + + describe('stage not found', () => { + test + .stdout() + .stderr() + .it('errors when stage has no associated pipeline', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockConnection.query.resolves({ records: [] }); + + try { + await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + expect.fail('should have thrown'); + } 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); + mockConnection.query.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('deploy execution failure', () => { + test + .stdout() + .stderr() + .it('surfaces a clean error message on deploy failure', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getUndeployedWorkItemsStub.resolves({ undeployedWorkitemIds: ['a1B000000000001'] }); + validateDeployStub.resolves({ success: true, errorType: null, errorDetails: null }); + executeDeployStub.rejects(new Error('Invalid stage configuration')); + + try { + await CompleteCommand.run(['-o', 'testOrg', '-t', '1QVxx0000000001']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Invalid stage configuration'); + }); + }); +}); diff --git a/test/commands/devops/request/status.test.ts b/test/commands/devops/request/status.test.ts new file mode 100644 index 00000000..819dbb8e --- /dev/null +++ b/test/commands/devops/request/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 request 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/request/status.js', { + '../../../../src/utils/getPromotionStatus.js': { + getPromotionStatus: getPromotionStatusStub, + }, + }); + StatusCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getPromotionStatusStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('completed request', () => { + 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 request', () => { + 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'); + } + }); + }); +}); diff --git a/test/commands/devops/pull-request/create.test.ts b/test/commands/devops/review/create.test.ts similarity index 97% rename from test/commands/devops/pull-request/create.test.ts rename to test/commands/devops/review/create.test.ts index b7682f02..f5140f8e 100644 --- a/test/commands/devops/pull-request/create.test.ts +++ b/test/commands/devops/review/create.test.ts @@ -19,7 +19,7 @@ import { expect, test } from '@oclif/test'; import sinon from 'sinon'; import { Org } from '@salesforce/core'; -describe('devops pull-request create', () => { +describe('devops review create', () => { let sandbox: sinon.SinonSandbox; // eslint-disable-next-line @typescript-eslint/no-explicit-any let CreateCommand: any; @@ -29,7 +29,7 @@ describe('devops pull-request create', () => { const createPrStub = sinon.stub(); before(async () => { - const mod = await esmock('../../../../src/commands/devops/pull-request/create.js', { + const mod = await esmock('../../../../src/commands/devops/review/create.js', { '../../../../src/utils/createPullRequest.js': { fetchWorkItemDetail: fetchDetailStub, createPullRequest: createPrStub, diff --git a/test/commands/devops/work-item/combine.test.ts b/test/commands/devops/work-item/combine.test.ts new file mode 100644 index 00000000..88c43b37 --- /dev/null +++ b/test/commands/devops/work-item/combine.test.ts @@ -0,0 +1,239 @@ +/* + * 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 work-item combine', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let CombineCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { + id: '1', + getOrgId: () => '1', + getConnection: () => mockConnection, + getUsername: () => 'my-devops-org', + }; + const combineWorkItemsPrepareStub = sinon.stub(); + const getPipelineIdForProjectStub = sinon.stub(); + const resolveProjectIdFromWorkItemStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/work-item/combine.js', { + '../../../../src/utils/combineWorkItems.js': { + combineWorkItemsPrepare: combineWorkItemsPrepareStub, + }, + '../../../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: getPipelineIdForProjectStub, + }, + '../../../../src/utils/prepareWorkItem.js': { + resolveProjectIdFromWorkItem: resolveProjectIdFromWorkItemStub, + }, + }); + CombineCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + combineWorkItemsPrepareStub.reset(); + getPipelineIdForProjectStub.reset(); + resolveProjectIdFromWorkItemStub.reset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful combine', () => { + test + .stdout() + .stderr() + .it('prepares work items and prints success output', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: '1Qg000000000001', pipelineStageId: '05S000000000001' }); + getPipelineIdForProjectStub.resolves('0Xo000000000001'); + combineWorkItemsPrepareStub.resolves({ + success: true, + requestToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + errorCode: null, + errorMessage: null, + }); + + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '--child-work-item-id', + '1fkxx0000000003', + '-t', + '1QVxx0000000002', + ]); + + expect(ctx.stdout).to.contain('Work items prepared for custom promotion.'); + expect(ctx.stdout).to.contain('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(ctx.stdout).to.contain('sf devops work-item promote'); + + const callArgs = combineWorkItemsPrepareStub.firstCall.args[0]; + expect(callArgs.sourceStageId).to.equal('05S000000000001'); + expect(callArgs.targetStageId).to.equal('1QVxx0000000002'); + }); + }); + + describe('failed combine', () => { + test + .stdout() + .stderr() + .it('prints error details on failure', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: '1Qg000000000001', pipelineStageId: '05S000000000001' }); + getPipelineIdForProjectStub.resolves('0Xo000000000001'); + combineWorkItemsPrepareStub.resolves({ + success: false, + requestToken: null, + errorCode: 'ALM_ERR_002', + errorMessage: 'One or more work items have conflicting components.', + }); + + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '-t', + '1QVxx0000000002', + ]); + + expect(ctx.stdout).to.contain('Failed to prepare work items for custom promotion.'); + expect(ctx.stdout).to.contain('ALM_ERR_002'); + expect(ctx.stdout).to.contain('conflicting components'); + }); + }); + + describe('no pipeline found', () => { + test + .stdout() + .stderr() + .it('errors when no pipeline is found for the project', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: '1Qg000000000001', pipelineStageId: '05S000000000001' }); + getPipelineIdForProjectStub.resolves(undefined); + + try { + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '-t', + '1QVxx0000000002', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('No pipeline found'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error from resolveProjectId', async (ctx) => { + resolveProjectIdFromWorkItemStub.rejects(new Error("sObject type 'WorkItem' is not supported")); + + try { + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '-t', + '1QVxx0000000002', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error from combineWorkItemsPrepare', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: '1Qg000000000001', pipelineStageId: '05S000000000001' }); + getPipelineIdForProjectStub.resolves('0Xo000000000001'); + combineWorkItemsPrepareStub.rejects(new Error("sObject type 'WorkItem' is not supported")); + + try { + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '-t', + '1QVxx0000000002', + ]); + } 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 combineWorkItemsPrepare', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: '1Qg000000000001', pipelineStageId: '05S000000000001' }); + getPipelineIdForProjectStub.resolves('0Xo000000000001'); + combineWorkItemsPrepareStub.rejects(new Error('Network error')); + + try { + await CombineCommand.run([ + '-o', + 'my-devops-org', + '--parent-work-item-id', + '1fkxx0000000001', + '--child-work-item-id', + '1fkxx0000000002', + '-t', + '1QVxx0000000002', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/work-item/prepare.test.ts b/test/commands/devops/work-item/prepare.test.ts new file mode 100644 index 00000000..d585a89c --- /dev/null +++ b/test/commands/devops/work-item/prepare.test.ts @@ -0,0 +1,181 @@ +/* + * 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 work-item prepare', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let PrepareCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const prepareWorkItemStub = sinon.stub(); + const resolveProjectIdFromWorkItemStub = sinon.stub(); + const getPipelineIdForProjectStub = sinon.stub(); + + const mockWorkItemContext = { projectId: 'PROJ001', pipelineStageId: '05S000000000001' }; + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/work-item/prepare.js', { + '../../../../src/utils/prepareWorkItem.js': { + prepareWorkItem: prepareWorkItemStub, + resolveProjectIdFromWorkItem: resolveProjectIdFromWorkItemStub, + }, + '../../../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: getPipelineIdForProjectStub, + }, + '@salesforce/sf-plugins-core': { + ...(await import('@salesforce/sf-plugins-core')), + Flags: { + ...(await import('@salesforce/sf-plugins-core')).Flags, + requiredOrg: () => ({ + type: 'option' as const, + char: 'o' as const, + parse: async () => mockOrg, + default: async () => mockOrg, + required: true, + }), + orgApiVersion: () => ({ + type: 'option' as const, + parse: async () => '65.0', + required: false, + }), + }, + }, + }); + PrepareCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + prepareWorkItemStub.reset(); + resolveProjectIdFromWorkItemStub.reset(); + getPipelineIdForProjectStub.reset(); + resolveProjectIdFromWorkItemStub.resolves(mockWorkItemContext); + getPipelineIdForProjectStub.resolves('PIPE001'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful preparation', () => { + test + .stdout() + .stderr() + .it('logs success message with request token and resolves sourceStageId from work item', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + prepareWorkItemStub.resolves({ + success: true, + requestToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + errorCode: null, + errorMessage: null, + }); + + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + + expect(ctx.stdout).to.contain('prepared for one-off promotion'); + expect(ctx.stdout).to.contain('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + + const callArgs = prepareWorkItemStub.firstCall.args[0]; + expect(callArgs.sourceStageId).to.equal('05S000000000001'); + expect(callArgs.targetStageId).to.equal('1QVxx0000000002'); + }); + }); + + describe('failure response', () => { + test + .stdout() + .stderr() + .it('logs failure message with error code and message', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + prepareWorkItemStub.resolves({ + success: false, + requestToken: null, + errorCode: 'ALM_ERR_001', + errorMessage: 'Source stage and target stage are not compatible for one-off promotion.', + }); + + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + + expect(ctx.stdout).to.contain('Failed to prepare work item'); + expect(ctx.stdout).to.contain('ALM_ERR_001'); + expect(ctx.stdout).to.contain('not compatible for one-off promotion'); + }); + }); + + describe('no pipeline found', () => { + test + .stdout() + .stderr() + .it('errors when no pipeline is associated with the project', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + getPipelineIdForProjectStub.resolves(undefined); + + try { + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('No pipeline 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); + resolveProjectIdFromWorkItemStub.rejects(new Error("sObject type 'WorkItem' is not supported")); + + try { + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + } 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 prepare call', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + prepareWorkItemStub.rejects(new Error('Network error')); + + try { + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/utils/combineWorkItems.test.ts b/test/utils/combineWorkItems.test.ts new file mode 100644 index 00000000..65670922 --- /dev/null +++ b/test/utils/combineWorkItems.test.ts @@ -0,0 +1,104 @@ +/* + * 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 } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { combineWorkItemsPrepare } from '../../src/utils/combineWorkItems.js'; + +describe('combineWorkItems utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls POST endpoint with correct body', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: true, + requestToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }); + + const result = await combineWorkItemsPrepare({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + parentWorkItemId: '0Wx000000000001', + childWorkItemIds: ['0Wx000000000002', '0Wx000000000003'], + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + + expect(result.success).to.be.true; + expect(result.requestToken).to.equal('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(result.errorCode).to.be.null; + expect(result.errorMessage).to.be.null; + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/pipelines/0Xo000000000001/promote/combine/prepare'); + expect(callArgs.method).to.equal('POST'); + + const body = JSON.parse(callArgs.body as string); + expect(body.parentWorkitemId).to.equal('0Wx000000000001'); + expect(body.childWorkitemsId).to.deep.equal(['0Wx000000000002', '0Wx000000000003']); + expect(body.sourceStageId).to.equal('05S000000000001'); + expect(body.targetStageId).to.equal('05S000000000002'); + }); + + it('returns failure result with error code and message', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: false, + errorCode: 'ALM_ERR_002', + errorMessage: 'One or more work items have conflicting components.', + }); + + const result = await combineWorkItemsPrepare({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + parentWorkItemId: '0Wx000000000001', + childWorkItemIds: ['0Wx000000000002'], + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + + expect(result.success).to.be.false; + expect(result.requestToken).to.be.null; + expect(result.errorCode).to.equal('ALM_ERR_002'); + expect(result.errorMessage).to.contain('conflicting components'); + }); + + it('propagates connection errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Network timeout')); + + try { + await combineWorkItemsPrepare({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + parentWorkItemId: '0Wx000000000001', + childWorkItemIds: ['0Wx000000000002'], + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network timeout'); + } + }); +}); diff --git a/test/utils/prepareWorkItem.test.ts b/test/utils/prepareWorkItem.test.ts new file mode 100644 index 00000000..d324eb28 --- /dev/null +++ b/test/utils/prepareWorkItem.test.ts @@ -0,0 +1,137 @@ +/* + * 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 } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { prepareWorkItem, resolveProjectIdFromWorkItem } from '../../src/utils/prepareWorkItem.js'; + +describe('prepareWorkItem', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls prepare endpoint and returns success with requestToken', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: true, + requestToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await prepareWorkItem({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemId: '0Wx000000000001', + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + + expect(result.success).to.be.true; + expect(result.requestToken).to.equal('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(result.errorCode).to.be.null; + expect(result.errorMessage).to.be.null; + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/pipelines/0XB000000000001/promote/oneoff/prepare'); + expect(callArgs.method).to.equal('POST'); + const body = JSON.parse(callArgs.body as string); + expect(body.selectedWorkItemId).to.equal('0Wx000000000001'); + expect(body.sourceStageId).to.equal('05S000000000001'); + expect(body.targetStageId).to.equal('05S000000000002'); + }); + + it('returns failure with error code and message', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: false, + errorCode: 'ALM_ERR_001', + errorMessage: 'Source stage and target stage are not compatible for one-off promotion.', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await prepareWorkItem({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemId: '0Wx000000000001', + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + + expect(result.success).to.be.false; + expect(result.requestToken).to.be.null; + expect(result.errorCode).to.equal('ALM_ERR_001'); + expect(result.errorMessage).to.equal('Source stage and target stage are not compatible for one-off promotion.'); + }); + + it('propagates API errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Internal Server Error')); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + try { + await prepareWorkItem({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemId: '0Wx000000000001', + sourceStageId: '05S000000000001', + targetStageId: '05S000000000002', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Internal Server Error'); + } + }); +}); + +describe('resolveProjectIdFromWorkItem', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns project ID and pipeline stage ID from work item', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ + records: [{ DevopsProjectId: 'PROJ001', DevopsPipelineStageId: '05S000000000001' }], + }); + + const result = await resolveProjectIdFromWorkItem(connectionStub as unknown as Connection, '0Wx000000000001'); + expect(result.projectId).to.equal('PROJ001'); + expect(result.pipelineStageId).to.equal('05S000000000001'); + }); + + it('throws when work item is not found', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ + records: [], + }); + + try { + await resolveProjectIdFromWorkItem(connectionStub as unknown as Connection, '0Wx999999999999'); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('0Wx999999999999'); + expect((e as Error).message).to.contain('not found'); + } + }); +}); diff --git a/test/utils/promoteStage.test.ts b/test/utils/promoteStage.test.ts new file mode 100644 index 00000000..d9acc994 --- /dev/null +++ b/test/utils/promoteStage.test.ts @@ -0,0 +1,128 @@ +/* + * 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 } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { promoteStage } from '../../src/utils/promoteStage.js'; + +describe('promoteStage utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls promote endpoint with correct payload', async () => { + const mockResponse = { + requestId: 'f43953d8-9b20-4dc3-8830-5ade632db0b1', + status: 'SUBMITTED', + message: 'Submitted for promotion', + promotedWorkitemIds: ['0Wx000000000001', '0Wx000000000002'], + }; + (connectionStub.request as sinon.SinonStub).resolves(mockResponse); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await promoteStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemIds: ['0Wx000000000001', '0Wx000000000002'], + targetStageId: '05S000000000002', + }); + + expect(result.requestId).to.equal('f43953d8-9b20-4dc3-8830-5ade632db0b1'); + expect(result.status).to.equal('SUBMITTED'); + expect(result.promotedWorkitemIds).to.deep.equal(['0Wx000000000001', '0Wx000000000002']); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/pipelines/0XB000000000001/promote'); + expect(callArgs.method).to.equal('POST'); + + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.workitemIds).to.deep.equal(['0Wx000000000001', '0Wx000000000002']); + expect(body.targetStageId).to.equal('05S000000000002'); + expect(body.allWorkItemsInStage).to.equal(false); + expect(body.isCheckDeploy).to.equal(false); + expect(body).to.not.have.property('promoteOptions'); + expect(body).to.not.have.property('fullDeploy'); + expect(body).to.not.have.property('testLevel'); + expect(body.deployOptions).to.deep.equal({ testLevel: 'Default', isFullDeploy: false, runTests: [] }); + }); + + it('passes fullDeploy, testLevel, and runTests into deployOptions', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + requestId: 'abc', + status: 'SUBMITTED', + message: '', + promotedWorkitemIds: [], + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + await promoteStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemIds: ['0Wx000000000001'], + targetStageId: '05S000000000002', + fullDeploy: true, + testLevel: 'RunLocalTests', + runTests: ['MyTest'], + }); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string) as Record< + string, + unknown + >; + expect(body.deployOptions).to.deep.equal({ testLevel: 'RunLocalTests', isFullDeploy: true, runTests: ['MyTest'] }); + }); + + it('propagates API errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + try { + await promoteStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemIds: ['0Wx000000000001'], + targetStageId: '05S000000000002', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Bad Request'); + } + }); + + it('handles missing fields in response', async () => { + (connectionStub.request as sinon.SinonStub).resolves({}); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await promoteStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemIds: ['0Wx000000000001'], + targetStageId: '05S000000000002', + }); + + expect(result.requestId).to.equal(''); + expect(result.status).to.equal(''); + expect(result.message).to.equal(''); + expect(result.promotedWorkitemIds).to.deep.equal([]); + }); +});