diff --git a/command-snapshot.json b/command-snapshot.json index bbbc58bb..bbbf967f 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -68,6 +68,14 @@ "flags": ["api-version", "flags-dir", "json", "pipeline-id", "stage-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:pipeline:stage:update", + "flagAliases": [], + "flagChars": ["n", "o", "s"], + "flags": ["api-version", "flags-dir", "json", "name", "stage-id", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:pipeline:update", @@ -110,6 +118,7 @@ "deploy-all", "flags-dir", "json", + "skip-validation", "stage-id", "target-org", "target-stage-id", @@ -137,6 +146,14 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:promotion:validate", + "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:request:status", @@ -170,6 +187,14 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:stage:branch:delete", + "flagAliases": [], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "stage-id", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:stage:environment:add", diff --git a/messages/devops.pipeline.stage.update.md b/messages/devops.pipeline.stage.update.md new file mode 100644 index 00000000..37992776 --- /dev/null +++ b/messages/devops.pipeline.stage.update.md @@ -0,0 +1,25 @@ +# summary + +Update a DevOps Center pipeline stage. + +# description + +Updates the name of a pipeline stage. + +# flags.stage-id.summary + +ID of the pipeline stage to update. + +# flags.name.summary + +New name for the pipeline stage. + +# examples + +- Rename a pipeline stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --stage-id 1QV000000000001 --name "Integration" + +# error.StageNotFound + +Stage "%s" not found. Check the stage ID and try again. diff --git a/messages/devops.promote.md b/messages/devops.promote.md index 9367e4e8..277f87da 100644 --- a/messages/devops.promote.md +++ b/messages/devops.promote.md @@ -48,6 +48,10 @@ ID of the source pipeline stage whose approved work items will be promoted. Mutu <%= config.bin %> <%= command.id %> --target-org my-devops-org --stage-id 1QVxx0000000001 --target-stage-id 1QVxx0000000002 --deploy-all +# flags.skip-validation.summary + +Skip pre-promote validation. By default, promotion is validated before proceeding to prevent promoting work items without an associated PR. Use this flag to bypass validation. + # error.NoModeFlag Provide either --work-item-id to promote specific work items or --stage-id to promote all approved work items from a stage. @@ -56,6 +60,18 @@ Provide either --work-item-id to promote specific work items or --stage-id to pr No work items found to promote from the source stage. Make sure there are approved work items before promoting. +# error.ValidationRequestFailed + +Failed to run pre-promote validation: %s + +# error.ValidationFailedCombineRequired + +Promotion blocked: work items must be combined before promoting. Run the combine command with the following details: + +# error.ValidationFailed + +Pre-promote validation failed (%s): %s + # error.PromoteFailed Failed to promote: %s diff --git a/messages/devops.promotion.complete.md b/messages/devops.promotion.complete.md index 2279b57e..be787518 100644 --- a/messages/devops.promotion.complete.md +++ b/messages/devops.promotion.complete.md @@ -18,15 +18,15 @@ ID of a specific work item to deploy. Omit to deploy all undeployed work items f # examples -- Complete a promotion for an out-of-sync pipeline stage. +- 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). +- 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. +- 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 diff --git a/messages/devops.promotion.validate.md b/messages/devops.promotion.validate.md new file mode 100644 index 00000000..545b40e5 --- /dev/null +++ b/messages/devops.promotion.validate.md @@ -0,0 +1,64 @@ +# summary + +Validate work item promotion for a pipeline stage. + +# description + +Validates whether the specified work items can be promoted to the target pipeline stage. Checks for VCS and object permission errors before a promotion is attempted. + +# flags.target-stage-id.summary + +ID of the target pipeline stage to validate promotion to. + +# flags.work-item-id.summary + +ID of a work item to validate for promotion. Specify multiple times for multiple work items. + +# examples + +- Validate promotion of a work item to a target stage: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 --work-item-id 1fk000000000001 + +- Validate promotion of multiple work items: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 --work-item-id 1fk000000000001 --work-item-id 1fk000000000002 + +# error.NoPipeline + +No pipeline found for work item "%s". Ensure the project has an associated pipeline. + +# error.ValidationFailed + +Validation failed (%s): %s + +# error.ValidationRequestFailed + +Validation request failed: %s + +# suggestion.SharedComponents + +The selected work items share one or more components. Choose one of these approaches: + +# suggestion.CombineOption + +Option 1 - Combine the work items and promote them as a single unit: + +# suggestion.PromoteOption + +Option 2 - Promote the work items as they are, without combining: + +# suggestion.CombineStepPrepare + +1. Combine the work items: + %s + +# suggestion.CombineStepPromote + +2. Promote the combined work item: + %s + +# suggestion.PromoteStep + +Run: +%s diff --git a/messages/devops.stage.branch.delete.md b/messages/devops.stage.branch.delete.md new file mode 100644 index 00000000..09967b3f --- /dev/null +++ b/messages/devops.stage.branch.delete.md @@ -0,0 +1,37 @@ +# summary + +Delete the source code repository branch associated with a pipeline stage. + +# description + +Removes the branch linked to the specified pipeline stage. Because branches are configured from right to left (a stage can have a branch only after the stage to its right does), removing a branch also removes the branches from all upstream stages (to the left) in the pipeline, keeping it in a valid state. The branch records in the repository are not deleted; only their association with the stages is removed. The stage must belong to an inactive pipeline. + +# flags.pipeline-id.summary + +ID of the pipeline that contains the stage. Used to verify the pipeline is inactive before deleting. + +# flags.stage-id.summary + +ID of the pipeline stage whose branch you want to delete. + +# examples + +- Delete the branch associated with a pipeline stage: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 + +# error.PipelineAlreadyActive + +Pipeline %s is already active. A branch can only be removed from a stage in an inactive pipeline. + +# error.StageNotFound + +Pipeline stage "%s" doesn't exist in pipeline "%s". Check the stage ID and try again. + +# error.NoBranch + +Pipeline stage "%s" doesn't have a branch associated with it. There's nothing to delete. + +# error.BranchDeleteFailed + +Failed to delete branch from stage: %s diff --git a/schemas/devops-pipeline-stage-update.json b/schemas/devops-pipeline-stage-update.json new file mode 100644 index 00000000..9e87e76f --- /dev/null +++ b/schemas/devops-pipeline-stage-update.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PipelineStageUpdateResult", + "definitions": { + "PipelineStageUpdateResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["success", "stageId", "name"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-promote.json b/schemas/devops-promote.json index 393f8f37..d9a1cee7 100644 --- a/schemas/devops-promote.json +++ b/schemas/devops-promote.json @@ -19,10 +19,48 @@ "items": { "type": "string" } + }, + "combineDetails": { + "anyOf": [ + { + "$ref": "#/definitions/CombineDetails" + }, + { + "type": "null" + } + ] } }, "required": ["requestId", "status", "message", "promotedWorkitemIds"], "additionalProperties": false + }, + "CombineDetails": { + "type": "object", + "additionalProperties": {}, + "properties": { + "childWorkitemsId": { + "type": "array", + "items": { + "type": "string" + } + }, + "parentWorkitemId": { + "type": "string" + }, + "sharedComponentsList": { + "$ref": "#/definitions/SharedComponentsList" + } + } + }, + "SharedComponentsList": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Maps a comma-separated list of work item names to the components they share." } } } diff --git a/schemas/devops-promotion-validate.json b/schemas/devops-promotion-validate.json new file mode 100644 index 00000000..4295224e --- /dev/null +++ b/schemas/devops-promotion-validate.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/PromotionValidateResult", + "definitions": { + "PromotionValidateResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errorType": { + "type": ["string", "null"] + }, + "errorDetails": { + "type": ["string", "null"] + }, + "combineDetails": { + "anyOf": [ + { + "$ref": "#/definitions/CombineDetails" + }, + { + "type": "null" + } + ] + }, + "suggestions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["success", "errorType", "errorDetails", "combineDetails", "suggestions"], + "additionalProperties": false + }, + "CombineDetails": { + "type": "object", + "additionalProperties": {}, + "properties": { + "childWorkitemsId": { + "type": "array", + "items": { + "type": "string" + } + }, + "parentWorkitemId": { + "type": "string" + }, + "sharedComponentsList": { + "$ref": "#/definitions/SharedComponentsList" + } + } + }, + "SharedComponentsList": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Maps a comma-separated list of work item names to the components they share." + } + } +} diff --git a/schemas/devops-stage-branch-delete.json b/schemas/devops-stage-branch-delete.json new file mode 100644 index 00000000..bd02abdf --- /dev/null +++ b/schemas/devops-stage-branch-delete.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DeleteStageBranchResult", + "definitions": { + "DeleteStageBranchResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "branchName": { + "type": "string" + }, + "clearedStages": { + "type": "array", + "items": { + "$ref": "#/definitions/ClearedStage" + } + }, + "error": { + "type": "string" + } + }, + "required": ["success", "stageId"], + "additionalProperties": false + }, + "ClearedStage": { + "type": "object", + "properties": { + "stageId": { + "type": "string" + }, + "stageName": { + "type": "string" + }, + "branchName": { + "type": "string" + } + }, + "required": ["stageId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/stage/update.ts b/src/commands/devops/pipeline/stage/update.ts new file mode 100644 index 00000000..ce4b7c7d --- /dev/null +++ b/src/commands/devops/pipeline/stage/update.ts @@ -0,0 +1,79 @@ +/* + * 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 { validateSalesforceId } from '../../../../utils/soqlUtils.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.pipeline.stage.update'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export type PipelineStageUpdateResult = { + success: boolean; + stageId: string; + name: string; +}; + +export default class DevopsPipelineStageUpdate 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(), + 'stage-id': Flags.salesforceId({ + summary: messages.getMessage('flags.stage-id.summary'), + required: true, + char: 's', + }), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + required: true, + char: 'n', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineStageUpdate); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const stageId = flags['stage-id']; + const name = flags['name']; + + validateSalesforceId(stageId, 'stage'); + + try { + await connection.sobject('DevopsPipelineStage').update({ Id: stageId, Name: name }); + } 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.toLowerCase().includes('not found') || errMsg.includes('entity is deleted')) { + this.error(messages.getMessage('error.StageNotFound', [stageId])); + } + throw error; + } + + this.log(`Successfully updated stage "${name}".`); + this.log(` Stage ID: ${stageId}`); + this.log(` Name: ${name}`); + + return { success: true, stageId, name }; + } +} diff --git a/src/commands/devops/promote.ts b/src/commands/devops/promote.ts index 775e62b2..97eac967 100644 --- a/src/commands/devops/promote.ts +++ b/src/commands/devops/promote.ts @@ -21,6 +21,12 @@ 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'; +import { + validatePromotion, + CombineDetails, + ValidatePromotionResult, + formatValidationDetails, +} from '../../utils/promotionUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promote'); @@ -31,8 +37,12 @@ export type PromoteResult = { status: string; message: string; promotedWorkitemIds: string[]; + combineDetails?: CombineDetails | null; }; +const PROMOTABLE_STATUSES = ['READY_TO_PROMOTE', 'PROMOTED'] as const; +const PROMOTABLE_STATUS_LIST = PROMOTABLE_STATUSES.join("', '"); + export default class DevopsPromote extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -66,8 +76,27 @@ export default class DevopsPromote extends SfCommand { 'deploy-all': deployAll, 'test-level': testLevelFlag(), tests: specificTestsNoChar, + 'skip-validation': Flags.boolean({ + summary: messages.getMessage('flags.skip-validation.summary'), + default: false, + }), }; + private static async assertWorkItemsPromotable(connection: Connection, workItemIds: string[]): Promise { + const idList = workItemIds.map((id) => `'${id}'`).join(', '); + const result = await connection.query<{ Id: string; Status: string }>( + `SELECT Id, Status FROM WorkItem WHERE Id IN (${idList})` + ); + const ineligible = result.records.filter((r) => !(PROMOTABLE_STATUSES as readonly string[]).includes(r.Status)); + if (ineligible.length > 0) { + const details = ineligible.map((r) => `${r.Id} (${r.Status})`).join(', '); + throw new Error( + `The following work items are not eligible for promotion: ${details}. Only work items with status READY_TO_PROMOTE or PROMOTED can be promoted.` + ); + } + return result.records.map((r) => r.Id); + } + private static async fetchStageWorkItems( connection: Connection, pipelineId: string, @@ -92,7 +121,7 @@ export default class DevopsPromote extends SfCommand { } const workItemResult = await connection.query<{ Id: string }>( - `SELECT Id FROM WorkItem WHERE DevopsPipelineStageId = '${sourceStageId}' LIMIT 200` + `SELECT Id FROM WorkItem WHERE DevopsPipelineStageId = '${sourceStageId}' AND Status IN ('${PROMOTABLE_STATUS_LIST}') LIMIT 200` ); return workItemResult.records.map((r) => r.Id); } @@ -120,7 +149,7 @@ export default class DevopsPromote extends SfCommand { ); } pipelineId = pid; - resolvedWorkItemIds = workItemIds; + resolvedWorkItemIds = await DevopsPromote.assertWorkItemsPromotable(connection, workItemIds); } else { // Stage path: resolve pipelineId from the source stage const sid = sourceStageId!; @@ -139,6 +168,10 @@ export default class DevopsPromote extends SfCommand { } } + if (!flags['skip-validation']) { + await this.runValidation(connection, pipelineId, resolvedWorkItemIds, targetStageId, !workItemIds?.length); + } + let apiResult: PromoteStageResult; try { apiResult = await promoteStage({ @@ -155,7 +188,8 @@ export default class DevopsPromote extends SfCommand { if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); } - const cleanMsg = errMsg.split('<')[0].trim(); + const htmlStripped = errMsg.split('<')[0].trim(); + const cleanMsg = htmlStripped.replace(/^[A-Z][A-Z0-9_]+:/, '').trim(); this.error(messages.getMessage('error.PromoteFailed', [cleanMsg])); } @@ -176,4 +210,38 @@ export default class DevopsPromote extends SfCommand { promotedWorkitemIds: apiResult.promotedWorkitemIds, }; } + + private async runValidation( + connection: Connection, + pipelineId: string, + workItemIds: string[], + targetStageId: string, + allWorkItemsInStage = false + ): Promise { + let result: ValidatePromotionResult; + try { + result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, false, allWorkItemsInStage); + } 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 htmlStripped = errMsg.split('<')[0].trim(); + const cleanMsg = htmlStripped.replace(/^[A-Z][A-Z0-9_]+:/, '').trim(); + this.error(messages.getMessage('error.ValidationRequestFailed', [cleanMsg])); + } + + if (!result.success) { + if (result.combineDetails) { + this.log(messages.getMessage('error.ValidationFailedCombineRequired')); + this.log(JSON.stringify(result.combineDetails, null, 2)); + } + this.error( + messages.getMessage('error.ValidationFailed', [ + result.errorType ?? '', + formatValidationDetails(result.errorDetails), + ]) + ); + } + } } diff --git a/src/commands/devops/promotion/complete.ts b/src/commands/devops/promotion/complete.ts index f9ad678e..8f18ab47 100644 --- a/src/commands/devops/promotion/complete.ts +++ b/src/commands/devops/promotion/complete.ts @@ -21,7 +21,7 @@ import { validateDeploy, executeDeploy, DeployStageResult, -} from '../../../utils/deployStage.js'; +} from '../../../utils/promotionUtils.js'; import { deployAll, testLevel, specificTestsNoChar } from '../../../common/flags/promote/promoteFlags.js'; import { validateSalesforceId } from '../../../utils/soqlUtils.js'; diff --git a/src/commands/devops/promotion/validate.ts b/src/commands/devops/promotion/validate.ts new file mode 100644 index 00000000..44e48eae --- /dev/null +++ b/src/commands/devops/promotion/validate.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Messages } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { + validatePromotion, + CombineDetails, + ValidatePromotionResult, + formatValidationDetails, + hasSharedComponents, +} from '../../../utils/promotionUtils.js'; +import { validateSalesforceId } from '../../../utils/soqlUtils.js'; +import { 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.promotion.validate'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export type PromotionValidateResult = { + success: boolean; + errorType: string | null; + errorDetails: string | null; + combineDetails: CombineDetails | null; + suggestions: string[]; +}; + +export default class DevopsPromotionValidate 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: true, + multiple: true, + startsWith: '1fk', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPromotionValidate); + const connection = flags['target-org'].getConnection(flags['api-version']); + const targetStageId = flags['target-stage-id']; + const workItemIds = flags['work-item-id']; + + validateSalesforceId(targetStageId, 'target stage'); + for (const id of workItemIds) { + validateSalesforceId(id, 'work item'); + } + + let pipelineId: string; + try { + const { projectId } = await resolveProjectIdFromWorkItem(connection, workItemIds[0]); + const pid = await getPipelineIdForProject(connection, projectId); + if (!pid) { + this.error(messages.getMessage('error.NoPipeline', [workItemIds[0]])); + } + pipelineId = pid; + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + let result: ValidatePromotionResult; + try { + result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, true); + } 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.ValidationRequestFailed', [cleanMsg])); + } + + if (!result.success) { + this.error( + messages.getMessage('error.ValidationFailed', [ + result.errorType ?? '', + formatValidationDetails(result.errorDetails), + ]) + ); + } + + this.log(`Success: ${result.success}`); + if (result.combineDetails) { + this.log('Combine Details:'); + this.log(JSON.stringify(result.combineDetails, null, 2)); + } + + // Validation succeeded, which means a pull request already exists for each work item, so they + // can be promoted directly. When the work items also share components, the user has two choices: + // 1. combine them and promote the combined parent as a single unit, or + // 2. promote them as they are, without combining. + const suggestions: string[] = []; + if (hasSharedComponents(result.combineDetails)) { + suggestions.push(messages.getMessage('suggestion.SharedComponents')); + + // Option 1 — combine + promote. combineDetails carries the parent/child IDs to combine. + const parentId = result.combineDetails?.parentWorkitemId; + const childIds = result.combineDetails?.childWorkitemsId ?? []; + if (parentId && childIds.length > 0) { + const combineArgs = [ + `--parent-work-item-id ${parentId}`, + ...childIds.map((id) => `--child-work-item-id ${id}`), + `--target-stage-id ${targetStageId}`, + ].join(' '); + suggestions.push(messages.getMessage('suggestion.CombineOption')); + suggestions.push( + messages.getMessage('suggestion.CombineStepPrepare', [`sf devops work-item combine ${combineArgs}`]) + ); + // Combine merges the children into the parent, so only the parent is promoted. + suggestions.push( + messages.getMessage('suggestion.CombineStepPromote', [ + `sf devops promote --work-item-id ${parentId} --target-stage-id ${targetStageId}`, + ]) + ); + } + + // Option 2 — promote the work items as-is. Their PRs already exist (validation passed). + const promoteArgs = [ + ...workItemIds.map((id) => `--work-item-id ${id}`), + `--target-stage-id ${targetStageId}`, + ].join(' '); + suggestions.push(messages.getMessage('suggestion.PromoteOption')); + suggestions.push(messages.getMessage('suggestion.PromoteStep', [`sf devops promote ${promoteArgs}`])); + + this.log(''); + this.log('Suggestions:'); + suggestions.forEach((s) => this.log(` ${s}`)); + } + + return { + success: result.success, + errorType: result.errorType, + errorDetails: result.errorDetails, + combineDetails: result.combineDetails, + suggestions, + }; + } +} diff --git a/src/commands/devops/stage/branch/delete.ts b/src/commands/devops/stage/branch/delete.ts new file mode 100644 index 00000000..6f81495e --- /dev/null +++ b/src/commands/devops/stage/branch/delete.ts @@ -0,0 +1,119 @@ +/* + * 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 { ClearedStage, deleteStageBranch, DeleteStageBranchResult } from '../../../../utils/deleteStageBranch.js'; +import { computeUpstreamStageIds, fetchPipelineStages } from '../../../../utils/pipelineUtils.js'; +import { PipelineStageRecord } from '../../../../utils/types.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.stage.branch.delete'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsStageBranchDelete extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'pipeline-id': Flags.salesforceId({ + summary: messages.getMessage('flags.pipeline-id.summary'), + required: true, + char: undefined, + }), + 'stage-id': Flags.salesforceId({ + summary: messages.getMessage('flags.stage-id.summary'), + required: true, + char: undefined, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsStageBranchDelete); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const stageId = flags['stage-id']; + + // The branch can only be removed while the pipeline is inactive. + const pipelineQueryResult = await connection.query( + `SELECT IsActive FROM DevopsPipeline WHERE Id = '${pipelineId}' LIMIT 1` + ); + const pipelineRecord = (pipelineQueryResult.records ?? [])[0] as { IsActive?: boolean } | undefined; + if (pipelineRecord?.IsActive) { + this.error(messages.getMessage('error.PipelineAlreadyActive', [pipelineId])); + } + + let 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 targetStage = stages.find((s) => s.Id === stageId); + if (!targetStage) { + this.error(messages.getMessage('error.StageNotFound', [stageId, pipelineId])); + } + + const branchName = targetStage.SourceCodeRepositoryBranch?.Name; + if (!branchName) { + this.error(messages.getMessage('error.NoBranch', [targetStage.Name ?? stageId])); + } + + // Branches are configured right-to-left, so removing this stage's branch must also remove the + // branch from every upstream (left) stage to keep the pipeline in a valid state. + const upstreamStageIds = computeUpstreamStageIds(stages, stageId); + const stagesById = new Map(stages.map((s) => [s.Id, s])); + const stagesToClear: ClearedStage[] = [targetStage, ...upstreamStageIds.map((id) => stagesById.get(id))] + .filter((s): s is PipelineStageRecord => Boolean(s?.SourceCodeRepositoryBranch?.Name)) + .map((s) => ({ stageId: s.Id, stageName: s.Name, branchName: s.SourceCodeRepositoryBranch?.Name })); + + let result: DeleteStageBranchResult; + try { + result = await deleteStageBranch(connection, stageId, stagesToClear); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + if (result.success) { + this.log(`Successfully deleted branch "${result.branchName ?? ''}" from the stage.`); + this.log(` Stage ID: ${stageId}`); + this.log(` Pipeline ID: ${pipelineId}`); + const cascaded = (result.clearedStages ?? []).filter((s) => s.stageId !== stageId); + if (cascaded.length > 0) { + this.log(''); + this.log('Also removed branches from upstream stages:'); + cascaded.forEach((s) => this.log(` - ${s.stageName ?? s.stageId}: "${s.branchName ?? ''}"`)); + } + } else { + this.error(messages.getMessage('error.BranchDeleteFailed', [result.error ?? ''])); + } + + return result; + } +} diff --git a/src/commands/devops/work-item/prepare.ts b/src/commands/devops/work-item/prepare.ts index a548aa65..2af1df41 100644 --- a/src/commands/devops/work-item/prepare.ts +++ b/src/commands/devops/work-item/prepare.ts @@ -69,10 +69,6 @@ export default class DevopsWorkItemPrepare extends SfCommand { + const encoded = encodeURIComponent(owner); try { - execSync(`gh api users/${encodeURIComponent(owner)}`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); + // Try user first, then org — owner may be either a personal account or an organization + try { + execSync(`gh api users/${encoded}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + return; + } catch (e: unknown) { + const stderr = String((e as { stderr?: string }).stderr ?? ''); + const stdout = String((e as { stdout?: string }).stdout ?? ''); + if (!stderr.includes('404') && !stdout.includes('"Not Found"')) throw e; + // 404 on users/ — try orgs/ + } + execSync(`gh api orgs/${encoded}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); return; } catch (e: unknown) { const stderr = String((e as { stderr?: string }).stderr ?? ''); @@ -87,14 +95,15 @@ export async function validateGitHubOwner(owner: string, token?: string, fetchFn } if (!token) return; - const response = await fetchFn(`https://api.github.com/users/${encodeURIComponent(owner)}`, { - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - Authorization: `Bearer ${token}`, - }, - }); - if (response.status === 404) throw new GitHubOwnerNotFoundError(owner); + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}`, + }; + const userRes = await fetchFn(`https://api.github.com/users/${encoded}`, { headers }); + if (userRes.status !== 404) return; + const orgRes = await fetchFn(`https://api.github.com/orgs/${encoded}`, { headers }); + if (orgRes.status === 404) throw new GitHubOwnerNotFoundError(owner); // 403 or other non-404: skip validation } diff --git a/src/utils/deleteStageBranch.ts b/src/utils/deleteStageBranch.ts new file mode 100644 index 00000000..705eaf66 --- /dev/null +++ b/src/utils/deleteStageBranch.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; + +export type ClearedStage = { + stageId: string; + stageName?: string; + branchName?: string; +}; + +export type DeleteStageBranchResult = { + success: boolean; + stageId: string; + branchName?: string; + clearedStages?: ClearedStage[]; + error?: string; +}; + +/** + * Removes the branch associated with a pipeline stage, cascading to every upstream (left) stage. + * + * Branches are configured right-to-left, so a stage can only have a branch once the stage to its + * right does. Clearing a branch therefore also clears the branch on every upstream stage to keep + * the pipeline in a valid state. Each stage's SourceCodeRepositoryBranchId lookup is cleared; the + * branch records themselves are left untouched. + */ +export async function deleteStageBranch( + connection: Connection, + targetStageId: string, + stagesToClear: ClearedStage[] +): Promise { + validateSalesforceId(targetStageId, 'stage'); + stagesToClear.forEach((s) => validateSalesforceId(s.stageId, 'stage')); + + const updates = stagesToClear.map((s) => ({ Id: s.stageId, SourceCodeRepositoryBranchId: null })); + const rawResults = await connection.sobject('DevopsPipelineStage').update(updates); + const results = (Array.isArray(rawResults) ? rawResults : [rawResults]) as unknown as Array<{ + success: boolean; + errors?: Array<{ message: string }>; + }>; + + const failures = results.filter((r) => !r.success); + if (failures.length > 0) { + const errorMsg = failures.flatMap((r) => r.errors?.map((e) => e.message) ?? ['Unknown error']).join('; '); + return { success: false, stageId: targetStageId, error: errorMsg }; + } + + const target = stagesToClear.find((s) => s.stageId === targetStageId); + return { + success: true, + stageId: targetStageId, + branchName: target?.branchName, + clearedStages: stagesToClear, + }; +} diff --git a/src/utils/pipelineUtils.ts b/src/utils/pipelineUtils.ts index 804630f1..5095de64 100644 --- a/src/utils/pipelineUtils.ts +++ b/src/utils/pipelineUtils.ts @@ -63,3 +63,37 @@ export function findStageById( export function getBranchNameFromStage(stage: PipelineStageRecord | undefined): string | undefined { return stage?.SourceCodeRepositoryBranch?.Name ?? undefined; } + +/** + * Returns the IDs of all stages upstream (to the left) of the given stage, i.e. every stage + * whose promotion path eventually reaches it by following NextStageId. The target stage itself + * is not included. Order is nearest-to-farthest from the target. + * + * Branches are configured right-to-left (a stage can only have a branch once the stage to its + * right does), so removing a branch must cascade to every upstream stage to keep the pipeline valid. + */ +export function computeUpstreamStageIds(stages: PipelineStageRecord[], stageId: string): string[] { + const predecessorsByStageId = new Map(); + for (const stage of stages) { + if (stage.NextStageId) { + const predecessors = predecessorsByStageId.get(stage.NextStageId) ?? []; + predecessors.push(stage.Id); + predecessorsByStageId.set(stage.NextStageId, predecessors); + } + } + + const upstream: string[] = []; + const seen = new Set([stageId]); + const queue: string[] = [stageId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const predecessorId of predecessorsByStageId.get(current) ?? []) { + if (!seen.has(predecessorId)) { + seen.add(predecessorId); + upstream.push(predecessorId); + queue.push(predecessorId); + } + } + } + return upstream; +} diff --git a/src/utils/prepareWorkItem.ts b/src/utils/prepareWorkItem.ts index 3c526a1b..c38f9ac5 100644 --- a/src/utils/prepareWorkItem.ts +++ b/src/utils/prepareWorkItem.ts @@ -21,7 +21,7 @@ export type PrepareWorkItemParams = { connection: Connection; pipelineId: string; workItemId: string; - sourceStageId: string; + sourceStageId?: string; targetStageId: string; }; @@ -48,11 +48,9 @@ export async function prepareWorkItem(params: PrepareWorkItemParams): Promise = { selectedWorkItemId: workItemId, targetStageId }; + if (sourceStageId) payload.sourceStageId = sourceStageId; + const body = JSON.stringify(payload); const data = await connection.request({ method: 'POST', diff --git a/src/utils/deployStage.ts b/src/utils/promotionUtils.ts similarity index 55% rename from src/utils/deployStage.ts rename to src/utils/promotionUtils.ts index fd5e88cc..2794baf8 100644 --- a/src/utils/deployStage.ts +++ b/src/utils/promotionUtils.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { normalizeSalesforceId } from './soqlUtils.js'; export type UndeployedWorkItemsResult = { undeployedWorkitemIds: string[]; @@ -43,6 +44,38 @@ type ValidateDeployResponse = { errorDetails?: string; }; +/** Maps a comma-separated list of work item names to the components they share. */ +export type SharedComponentsList = Record; + +export type CombineDetails = { + childWorkitemsId?: string[]; + parentWorkitemId?: string; + sharedComponentsList?: SharedComponentsList; +} & Record; + +/** + * Returns true when the combine details describe work items that share one or more components. + * Shared components mean the work items can optionally be combined before promotion. + */ +export function hasSharedComponents(combineDetails: CombineDetails | null): boolean { + const shared = combineDetails?.sharedComponentsList; + return Boolean(shared && Object.keys(shared).length > 0); +} + +export type ValidatePromotionResult = { + success: boolean; + errorType: string | null; + errorDetails: string | null; + combineDetails: CombineDetails | null; +}; + +type ValidatePromotionResponse = { + success?: boolean; + errorType?: string; + errorDetails?: string; + combineDetails?: CombineDetails; +}; + type DeployStageResponse = { requestId?: string; status?: string; @@ -86,6 +119,68 @@ export async function validateDeploy( }; } +const VALIDATION_REASON_LABELS: Record = { + PR_DOES_NOT_EXIST: 'no pull request exists', + PR_NOT_MERGED: 'pull request has not been merged', + BRANCH_NOT_FOUND: 'source branch not found', +}; + +export function formatValidationDetails(raw: string | null): string { + if (!raw) return ''; + const decoded = raw + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + try { + const parsed = JSON.parse(decoded) as Array<{ reason?: string; workItem?: string }>; + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed + .map((entry) => { + const label = entry.reason ? VALIDATION_REASON_LABELS[entry.reason] ?? entry.reason : ''; + return entry.workItem ? `${entry.workItem}: ${label}` : label; + }) + .join(', '); + } + } catch { + // not JSON — fall through + } + return decoded; +} + +/** + * POST /services/data/vXX.X/connect/devops/pipelines/{pipelineId}/validatePromote + * Full variant that accepts checkCombineDetails and returns combineDetails. + */ +export async function validatePromotion( + connection: Connection, + pipelineId: string, + workItemIds: string[], + targetStageId: string, + checkCombineDetails = false, + allWorkItemsInStage = false +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/validatePromote`; + const payload: Record = { + selectedWorkItemIds: workItemIds.map(normalizeSalesforceId), + targetStageId: normalizeSalesforceId(targetStageId), + allWorkItemsInStage, + }; + if (checkCombineDetails) payload.checkCombineDetails = true; + const response = await connection.request({ + method: 'POST', + url: path, + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + return { + success: response.success ?? true, + errorType: response.errorType ?? null, + errorDetails: response.errorDetails ?? null, + combineDetails: response.combineDetails ?? null, + }; +} + /** * POST /services/data/vXX.X/connect/devops/pipelines/{pipelineId}/promote * When workItemIds are provided, targets those specific items (allWorkItemsInStage: false). diff --git a/src/utils/soqlUtils.ts b/src/utils/soqlUtils.ts index f154d18e..64998bf1 100644 --- a/src/utils/soqlUtils.ts +++ b/src/utils/soqlUtils.ts @@ -49,6 +49,14 @@ export function validateSalesforceId(id: string, context?: string): string { return id; } +/** + * Trims an 18-character Salesforce ID to its 15-character form. + * 15-char IDs are returned unchanged. Accepts IDs that have already passed validateSalesforceId. + */ +export function normalizeSalesforceId(id: string): string { + return id.length === 18 ? id.slice(0, 15) : id; +} + /** * Validates that a string starts with the expected Salesforce object prefix. * Throws an error if validation fails. diff --git a/test/commands/devops/pipeline/stage/update.test.ts b/test/commands/devops/pipeline/stage/update.test.ts new file mode 100644 index 00000000..a4661847 --- /dev/null +++ b/test/commands/devops/pipeline/stage/update.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline stage update', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let UpdateCommand: any; + const updateStub = sinon.stub(); + const mockConnection = { + getApiVersion: () => '65.0', + sobject: sinon.stub().returns({ update: updateStub }), + }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/pipeline/stage/update.js', {}); + UpdateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + updateStub.reset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockConnection.sobject as any).resetHistory(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful update', () => { + test + .stdout() + .stderr() + .it('updates stage name and prints result', async (ctx) => { + updateStub.resolves({ id: '1QVxx0000000001', success: true }); + + const result = await UpdateCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000001', '-n', 'Integration']); + + expect(ctx.stdout).to.contain('Successfully updated stage "Integration"'); + expect(ctx.stdout).to.contain('1QVxx0000000001'); + expect(result.success).to.be.true; + expect(result.stageId).to.equal('1QVxx0000000001'); + expect(result.name).to.equal('Integration'); + + const updateArg = updateStub.firstCall.args[0] as Record; + expect(updateArg.Id).to.equal('1QVxx0000000001'); + expect(updateArg.Name).to.equal('Integration'); + }); + }); + + describe('stage not found', () => { + test + .stdout() + .stderr() + .it('errors when stage is not found', async (ctx) => { + updateStub.rejects(new Error('entity is deleted')); + + try { + await UpdateCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000001', '-n', 'Integration']); + 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) => { + updateStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await UpdateCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000001', '-n', 'Integration']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('unexpected error', () => { + test + .stdout() + .stderr() + .it('rethrows unexpected errors', async () => { + updateStub.rejects(new Error('Connection refused')); + + try { + await UpdateCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000001', '-n', 'Integration']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Connection refused'); + } + }); + }); +}); diff --git a/test/commands/devops/promote.test.ts b/test/commands/devops/promote.test.ts index 4591fd2b..6c7e1517 100644 --- a/test/commands/devops/promote.test.ts +++ b/test/commands/devops/promote.test.ts @@ -44,6 +44,7 @@ describe('devops promote', () => { const promoteStageStub = sinon.stub(); const resolveProjectIdFromWorkItemStub = sinon.stub(); const getPipelineIdForProjectStub = sinon.stub(); + const validatePromotionStub = sinon.stub(); before(async () => { const mod = await esmock('../../../src/commands/devops/promote.js', { @@ -56,6 +57,9 @@ describe('devops promote', () => { '../../../src/utils/pipelineUtils.js': { getPipelineIdForProject: getPipelineIdForProjectStub, }, + '../../../src/utils/promotionUtils.js': { + validatePromotion: validatePromotionStub, + }, }); PromoteCommand = mod.default; }); @@ -65,6 +69,15 @@ describe('devops promote', () => { promoteStageStub.reset(); resolveProjectIdFromWorkItemStub.reset(); getPipelineIdForProjectStub.reset(); + validatePromotionStub.reset(); + validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); + // Default: all queried work items are promotable + queryMock = sinon.stub().resolves({ + records: [ + { Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }, + { Id: '1fkxx0000000002', Status: 'READY_TO_PROMOTE' }, + ], + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); }); @@ -82,6 +95,7 @@ describe('devops promote', () => { .it('promotes a single work item', async (ctx) => { resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); promoteStageStub.resolves(mockPromoteResult); await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); @@ -95,6 +109,22 @@ describe('devops promote', () => { expect(args.targetStageId).to.equal('1QVxx0000000003'); }); + test + .stdout() + .stderr() + .it('uses canonical 18-char IDs from SOQL for both validation and promotion', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + // SOQL returns the full 18-char canonical ID even when user supplied 15-char + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkWt000000hGr7IAE', Status: 'READY_TO_PROMOTE' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkWt000000hGr7', '-t', '1QVxx0000000003']); + + expect(validatePromotionStub.firstCall.args[2]).to.deep.equal(['1fkWt000000hGr7IAE']); + expect(promoteStageStub.firstCall.args[0].workItemIds).to.deep.equal(['1fkWt000000hGr7IAE']); + }); + test .stdout() .stderr() @@ -186,6 +216,128 @@ describe('devops promote', () => { }); }); + // ── Work-item status eligibility ───────────────────────────────────────── + + describe('work-item path: status eligibility', () => { + test + .stdout() + .stderr() + .it('promotes work items with status "Ready to Promote"', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('SUBMITTED'); + expect(promoteStageStub.calledOnce).to.be.true; + }); + + test + .stdout() + .stderr() + .it('promotes work items with status "Promoted"', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'PROMOTED' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('SUBMITTED'); + expect(promoteStageStub.calledOnce).to.be.true; + }); + + test + .stdout() + .stderr() + .it('errors when a work item is not in an eligible status', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'In Progress' }] }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.contain('not eligible for promotion'); + expect(ctx.stderr).to.contain('1fkxx0000000001'); + }); + + test + .stdout() + .stderr() + .it('errors listing all ineligible IDs when multiple work items fail the status check', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ + records: [ + { Id: '1fkxx0000000001', Status: 'Open' }, + { Id: '1fkxx0000000002', Status: 'In Progress' }, + ], + }); + + try { + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.contain('1fkxx0000000001'); + expect(ctx.stderr).to.contain('1fkxx0000000002'); + }); + + test + .stdout() + .stderr() + .it('errors only for ineligible items when some pass and some fail the status check', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ + records: [ + { Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }, + { Id: '1fkxx0000000002', Status: 'IN_PROGRESS' }, + ], + }); + + try { + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.not.contain('1fkxx0000000001'); + expect(ctx.stderr).to.contain('1fkxx0000000002'); + }); + }); + // ── Stage path ──────────────────────────────────────────────────────────── describe('stage path: successful promotion', () => { @@ -348,6 +500,212 @@ describe('devops promote', () => { }); }); + // ── Validation gate ─────────────────────────────────────────────────────── + + describe('validation gate', () => { + test + .stdout() + .stderr() + .it('runs validation before promoting and proceeds when validation passes', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(validatePromotionStub.calledOnce).to.be.true; + const validateArgs = validatePromotionStub.firstCall.args; + expect(validateArgs[1]).to.equal('PIPE001'); + expect(validateArgs[2]).to.deep.equal(['1fkxx0000000001']); + expect(validateArgs[3]).to.equal('1QVxx0000000003'); + expect(validateArgs[4]).to.be.false; // checkCombineDetails always false in pre-promote gate + expect(validateArgs[5]).to.be.false; // allWorkItemsInStage false for work-item path + expect(promoteStageStub.calledOnce).to.be.true; + }); + + test + .stdout() + .stderr() + .it('blocks promotion and errors when validation fails without combine details', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'MISSING_PR', + errorDetails: 'No PR associated with work item', + combineDetails: null, + }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.contain('MISSING_PR'); + }); + + test + .stdout() + .stderr() + .it('formats HTML-encoded JSON error details into human-readable lines', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'CHANGE_REQUEST_VALIDATION', + errorDetails: + '[{"reason":"PR_DOES_NOT_EXIST","workItem":"WI-000132"}]', + combineDetails: null, + }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('WI-000132'); + expect(ctx.stderr).to.contain('no pull request exists'); + expect(ctx.stderr).to.not.contain('"'); + expect(ctx.stderr).to.not.contain('PR_DOES_NOT_EXIST'); + }); + + test + .stdout() + .stderr() + .it('formats multiple work item errors into a comma-separated list', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'CHANGE_REQUEST_VALIDATION', + errorDetails: + '[{"reason":"PR_DOES_NOT_EXIST","workItem":"WI-000132"},{"reason":"PR_NOT_MERGED","workItem":"WI-000133"}]', + combineDetails: null, + }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('WI-000132: no pull request exists'); + expect(ctx.stderr).to.contain('WI-000133: pull request has not been merged'); + }); + + test + .stdout() + .stderr() + .it('prints combine details and errors when validation fails with combine required', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'COMBINE_REQUIRED', + errorDetails: 'Work items must be combined', + combineDetails: { workItemIds: ['1fkxx0000000001', '1fkxx0000000002'] }, + }); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stdout).to.contain('1fkxx0000000001'); + expect(ctx.stdout).to.contain('1fkxx0000000002'); + expect(ctx.stderr).to.contain('COMBINE_REQUIRED'); + }); + + test + .stdout() + .stderr() + .it('skips validation and promotes directly when --skip-validation is passed', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-t', + '1QVxx0000000003', + '--skip-validation', + ]); + + expect(validatePromotionStub.called).to.be.false; + expect(promoteStageStub.calledOnce).to.be.true; + }); + + test + .stdout() + .stderr() + .it('surfaces DevOps Center not enabled error from validation', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.rejects(new Error("sObject type 'DevopsPromotionRequest' is not supported")); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + + test + .stdout() + .stderr() + .it('surfaces generic validation request failure', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.rejects(new Error('Network timeout')); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(promoteStageStub.called).to.be.false; + expect(ctx.stderr).to.contain('pre-promote validation'); + }); + + test + .stdout() + .stderr() + .it('runs validation using resolved work item IDs when --stage-id is used', async () => { + queryMock = sinon + .stub() + .onFirstCall() + .resolves({ records: [{ DevopsPipelineId: '1QVxx0000000001' }] }) + .onSecondCall() + .resolves({ records: [{ NextStageId: '1QVxx0000000003' }] }) + .onThirdCall() + .resolves({ records: [{ Id: '1fkxx0000000001' }, { Id: '1fkxx0000000002' }] }); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-s', '1QVxx0000000002', '-t', '1QVxx0000000003']); + + expect(validatePromotionStub.calledOnce).to.be.true; + expect(validatePromotionStub.firstCall.args[2]).to.deep.equal(['1fkxx0000000001', '1fkxx0000000002']); + expect(validatePromotionStub.firstCall.args[5]).to.be.true; // allWorkItemsInStage true for stage path + }); + }); + // ── Shared error cases ──────────────────────────────────────────────────── describe('API errors', () => { @@ -384,5 +742,27 @@ describe('devops promote', () => { expect(ctx.stderr).to.contain('Failed to promote'); }); + + test + .stdout() + .stderr() + .it('strips API error code prefix from promote error message', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + promoteStageStub.rejects( + new Error( + 'PROMOTION_SOURCE_CODE_REPOSITORY_BRANCH_NOT_FOUND:No source code repository branch found for work item: 1fkxx0000000001' + ) + ); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('No source code repository branch found'); + expect(ctx.stderr).to.not.contain('PROMOTION_SOURCE_CODE_REPOSITORY_BRANCH_NOT_FOUND:'); + }); }); }); diff --git a/test/commands/devops/promotion/complete.test.ts b/test/commands/devops/promotion/complete.test.ts index a4da035c..b2e5b9f4 100644 --- a/test/commands/devops/promotion/complete.test.ts +++ b/test/commands/devops/promotion/complete.test.ts @@ -34,7 +34,7 @@ describe('devops promotion complete', () => { before(async () => { const mod = await esmock('../../../../src/commands/devops/promotion/complete.js', { - '../../../../src/utils/deployStage.js': { + '../../../../src/utils/promotionUtils.js': { getUndeployedWorkItems: getUndeployedWorkItemsStub, validateDeploy: validateDeployStub, executeDeploy: executeDeployStub, diff --git a/test/commands/devops/promotion/validate.test.ts b/test/commands/devops/promotion/validate.test.ts new file mode 100644 index 00000000..3492c2a2 --- /dev/null +++ b/test/commands/devops/promotion/validate.test.ts @@ -0,0 +1,411 @@ +/* + * 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 validate', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ValidateCommand: any; + const validatePromotionStub = sinon.stub(); + const resolveProjectIdFromWorkItemStub = sinon.stub(); + const getPipelineIdForProjectStub = sinon.stub(); + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/promotion/validate.js', { + '../../../../src/utils/promotionUtils.js': { + validatePromotion: validatePromotionStub, + }, + '../../../../src/utils/prepareWorkItem.js': { + resolveProjectIdFromWorkItem: resolveProjectIdFromWorkItemStub, + }, + '../../../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: getPipelineIdForProjectStub, + }, + }); + ValidateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + validatePromotionStub.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(); + }); + + // ── Successful validation ───────────────────────────────────────────────── + + describe('successful validation', () => { + test + .stdout() + .stderr() + .it('prints success and returns result', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: true, + errorType: null, + errorDetails: null, + combineDetails: null, + }); + + const result = await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('Success: true'); + expect(result.success).to.be.true; + expect(result.errorType).to.be.null; + expect(result.combineDetails).to.be.null; + expect(result.suggestions).to.deep.equal([]); + }); + + test + .stdout() + .stderr() + .it('requests combine details from the API', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); + + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + // checkCombineDetails (5th arg) must be true so the API returns shared-component info. + expect(validatePromotionStub.firstCall.args[4]).to.be.true; + }); + + test + .stdout() + .stderr() + .it('validates multiple work items', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); + + await ValidateCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + + const args = validatePromotionStub.firstCall.args; + expect(args[2]).to.deep.equal(['1fkxx0000000001', '1fkxx0000000002']); + expect(args[3]).to.equal('1QVxx0000000003'); + }); + + test + .stdout() + .stderr() + .it('prints combine details when present', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: true, + errorType: null, + errorDetails: null, + combineDetails: { sharedComponents: ['ComponentA'] }, + }); + + const result = await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('Combine Details'); + expect(ctx.stdout).to.contain('ComponentA'); + expect(result.combineDetails).to.deep.equal({ sharedComponents: ['ComponentA'] }); + }); + + test + .stdout() + .stderr() + .it('suggests combine or promote when work items share components', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: true, + errorType: null, + errorDetails: null, + combineDetails: { + childWorkitemsId: ['1fkWt000000gzwjIAA'], + parentWorkitemId: '1fkWt000000hIjFIAU', + sharedComponentsList: { + 'WI-000122,WI-000136': ['HelloMCP2:ApexClass'], + }, + }, + }); + + const result = await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.contain('Success: true'); + expect(ctx.stdout).to.contain('Suggestions:'); + expect(ctx.stdout).to.contain('share one or more components'); + expect(ctx.stdout).to.contain('Option 1'); + expect(ctx.stdout).to.contain('Option 2'); + // Option 1: combine command populated with the real parent/child IDs from combineDetails + expect(ctx.stdout).to.contain( + 'sf devops work-item combine --parent-work-item-id 1fkWt000000hIjFIAU --child-work-item-id 1fkWt000000gzwjIAA --target-stage-id 1QVxx0000000003' + ); + // Option 1 promote: only the combined parent is promoted + expect(ctx.stdout).to.contain( + 'sf devops promote --work-item-id 1fkWt000000hIjFIAU --target-stage-id 1QVxx0000000003' + ); + // Option 2: promote the validated work items as-is (PR already exists, no prepare needed) + expect(ctx.stdout).to.contain( + 'sf devops promote --work-item-id 1fkxx0000000001 --target-stage-id 1QVxx0000000003' + ); + expect(ctx.stdout).to.not.contain('work-item prepare'); + expect(result.success).to.be.true; + expect(result.suggestions.length).to.be.greaterThan(0); + }); + + test + .stdout() + .stderr() + .it('lists all validated work items in the option 2 promote suggestion', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: true, + errorType: null, + errorDetails: null, + combineDetails: { + childWorkitemsId: ['1fkWt000000gzwjIAA', '1fkWt000000gzwkIAA'], + parentWorkitemId: '1fkWt000000hIjFIAU', + sharedComponentsList: { 'WI-000122,WI-000136': ['HelloMCP2:ApexClass'] }, + }, + }); + + await ValidateCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + + // Option 1 combine lists all children + expect(ctx.stdout).to.contain( + '--child-work-item-id 1fkWt000000gzwjIAA --child-work-item-id 1fkWt000000gzwkIAA' + ); + // Option 2 promote lists all validated work items together (no prepare step needed) + expect(ctx.stdout).to.contain( + 'sf devops promote --work-item-id 1fkxx0000000001 --work-item-id 1fkxx0000000002 --target-stage-id 1QVxx0000000003' + ); + expect(ctx.stdout).to.not.contain('work-item prepare'); + }); + + test + .stdout() + .stderr() + .it('does not suggest combining when there are no shared components', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: true, + errorType: null, + errorDetails: null, + combineDetails: { sharedComponentsList: {} }, + }); + + const result = await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(ctx.stdout).to.not.contain('Suggestions:'); + expect(result.suggestions).to.deep.equal([]); + }); + }); + + // ── Validation failures from API ────────────────────────────────────────── + + describe('API returns success: false', () => { + test + .stdout() + .stderr() + .it('errors with errorType and errorDetails when validation fails', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'TARGET_ORG_AUTH_MISSING', + errorDetails: 'User does not have a valid session to target stage organization', + combineDetails: null, + }); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('TARGET_ORG_AUTH_MISSING'); + expect(ctx.stderr).to.contain('valid session'); + }); + + test + .stdout() + .stderr() + .it('errors with vcsPermission error type', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ + success: false, + errorType: 'vcsPermission', + errorDetails: 'Missing VCS permissions', + combineDetails: null, + }); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('vcsPermission'); + }); + }); + + // ── Pipeline resolution errors ──────────────────────────────────────────── + + describe('pipeline resolution errors', () => { + 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 ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('No pipeline found'); + }); + + test + .stdout() + .stderr() + .it('errors when work item is not found', async (ctx) => { + resolveProjectIdFromWorkItemStub.rejects( + new Error("Work item '1fkxx0000000099' not found. Verify the work item ID and try again.") + ); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000099', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('not found'); + }); + }); + + // ── DevOps Center not enabled ───────────────────────────────────────────── + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error when org query fails', async (ctx) => { + resolveProjectIdFromWorkItemStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error when validatePromotion throws', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.rejects(new Error("sObject type 'DevopsPromotionRequest' is not supported")); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + // ── HTTP-level request failures ─────────────────────────────────────────── + + describe('request failures', () => { + test + .stdout() + .stderr() + .it('surfaces a clean error when the API request throws', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.rejects(new Error('Network timeout')); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Network timeout'); + }); + + test + .stdout() + .stderr() + .it('strips HTML from error messages', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.rejects(new Error('Bad Request
Extra HTML detail')); + + try { + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Bad Request'); + expect(ctx.stderr).to.not.contain('
'); + }); + }); +}); diff --git a/test/commands/devops/stage/branch/delete.test.ts b/test/commands/devops/stage/branch/delete.test.ts new file mode 100644 index 00000000..21130fc8 --- /dev/null +++ b/test/commands/devops/stage/branch/delete.test.ts @@ -0,0 +1,288 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops stage branch delete', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let DeleteBranchCommand: any; + const mockQueryStub = sinon.stub(); + const mockConnection = { getApiVersion: () => '65.0', query: mockQueryStub }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const deleteStageBranchStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + const stageWithBranch = [ + { Id: '0Xp000000000001', Name: 'Production', NextStageId: null, SourceCodeRepositoryBranch: { Name: 'main' } }, + ]; + + // Dev -> Integration -> Production. Removing Integration's branch should cascade to Dev. + const devStageId = '0Xp00000000Dev1'; + const intStageId = '0Xp00000000Int1'; + const prodStageId = '0Xp0000000Prod1'; + const linearStages = [ + { + Id: devStageId, + Name: 'Development', + NextStageId: intStageId, + SourceCodeRepositoryBranch: { Name: 'dev' }, + }, + { + Id: intStageId, + Name: 'Integration', + NextStageId: prodStageId, + SourceCodeRepositoryBranch: { Name: 'integration' }, + }, + { Id: prodStageId, Name: 'Production', NextStageId: null, SourceCodeRepositoryBranch: { Name: 'main' } }, + ]; + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/stage/branch/delete.js', { + '../../../../../src/utils/deleteStageBranch.js': { + deleteStageBranch: deleteStageBranchStub, + }, + '../../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + DeleteBranchCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deleteStageBranchStub.reset(); + fetchPipelineStagesStub.reset(); + mockQueryStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful deletion', () => { + test + .stdout() + .stderr() + .it('deletes branch and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.resolves(stageWithBranch); + deleteStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000001', + branchName: 'main', + clearedStages: [{ stageId: '0Xp000000000001', stageName: 'Production', branchName: 'main' }], + }); + + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + ]); + + expect(ctx.stdout).to.contain('Successfully deleted branch "main" from the stage.'); + expect(ctx.stdout).to.contain('0Xp000000000001'); + expect(ctx.stdout).to.contain('0Xo000000000001'); + }); + }); + + describe('cascade to upstream stages', () => { + test + .stdout() + .stderr() + .it('removes branches from upstream stages and reports them', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.resolves(linearStages); + deleteStageBranchStub.resolves({ + success: true, + stageId: intStageId, + branchName: 'integration', + clearedStages: [ + { stageId: intStageId, stageName: 'Integration', branchName: 'integration' }, + { stageId: devStageId, stageName: 'Development', branchName: 'dev' }, + ], + }); + + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + intStageId, + ]); + + expect(ctx.stdout).to.contain('Successfully deleted branch "integration" from the stage.'); + expect(ctx.stdout).to.contain('Also removed branches from upstream stages:'); + expect(ctx.stdout).to.contain('Development: "dev"'); + + // The command should ask the util to clear both the target and its upstream stage. + const stagesToClear = deleteStageBranchStub.firstCall.args[2] as Array<{ stageId: string }>; + const clearedIds = stagesToClear.map((s) => s.stageId); + expect(clearedIds).to.have.members([intStageId, devStageId]); + expect(clearedIds).to.not.include(prodStageId); + }); + }); + + describe('pipeline already active', () => { + test + .stdout() + .stderr() + .it('shows active pipeline error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: true }] }); + + try { + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already active'); + }); + }); + + describe('stage not found error', () => { + test + .stdout() + .stderr() + .it('shows friendly error when stage not found in pipeline', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.resolves(stageWithBranch); + + try { + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000099', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("doesn't exist in pipeline"); + }); + }); + + describe('stage has no branch', () => { + test + .stdout() + .stderr() + .it('shows error when stage has no branch associated', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.resolves([ + { Id: '0Xp000000000001', Name: 'Production', NextStageId: null, SourceCodeRepositoryBranch: null }, + ]); + + try { + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("doesn't have a branch"); + }); + }); + + 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); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + ]); + } 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 deleteStageBranch', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + mockQueryStub.resolves({ records: [{ IsActive: false }] }); + fetchPipelineStagesStub.resolves(stageWithBranch); + deleteStageBranchStub.rejects(new Error('Network error')); + + try { + await DeleteBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + ]); + 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 index d585a89c..68e0dc9f 100644 --- a/test/commands/devops/work-item/prepare.test.ts +++ b/test/commands/devops/work-item/prepare.test.ts @@ -100,6 +100,28 @@ describe('devops work-item prepare', () => { }); }); + describe('work item not assigned to a pipeline stage', () => { + test + .stdout() + .stderr() + .it('proceeds without sourceStageId when work item has no pipeline stage assignment', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + prepareWorkItemStub.resolves({ + success: true, + requestToken: 'tok-dev', + errorCode: null, + errorMessage: null, + }); + + await PrepareCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000002']); + + expect(ctx.stdout).to.contain('prepared for one-off promotion'); + expect(prepareWorkItemStub.calledOnce).to.be.true; + }); + }); + describe('failure response', () => { test .stdout() diff --git a/test/utils/addPipelineStage.test.ts b/test/utils/addPipelineStage.test.ts index faa93f39..56f2871f 100644 --- a/test/utils/addPipelineStage.test.ts +++ b/test/utils/addPipelineStage.test.ts @@ -54,6 +54,7 @@ describe('addPipelineStage utilities', () => { expect(createArg.Name).to.equal('Development'); expect(createArg.DevopsPipelineId).to.equal('0XB000000000001'); expect(createArg.NextStageId).to.equal('0Xc000000000002'); + expect(createArg.IsBundled).to.be.true; }); it('returns error when sObject create fails', async () => { diff --git a/test/utils/deleteStageBranch.test.ts b/test/utils/deleteStageBranch.test.ts new file mode 100644 index 00000000..6f2e7a32 --- /dev/null +++ b/test/utils/deleteStageBranch.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { deleteStageBranch } from '../../src/utils/deleteStageBranch.js'; + +describe('deleteStageBranch utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + let updateStub: sinon.SinonStub; + let sobjectStub: sinon.SinonStub; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + updateStub = sinon.stub(); + sobjectStub = sinon.stub().returns({ update: updateStub }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (connectionStub as any).sobject = sobjectStub; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('clears the branch lookup for the target stage', async () => { + updateStub.resolves([{ success: true }]); + + const result = await deleteStageBranch(connectionStub as unknown as Connection, 'STAGE1', [ + { stageId: 'STAGE1', stageName: 'Production', branchName: 'main' }, + ]); + + expect(sobjectStub.calledWith('DevopsPipelineStage')).to.be.true; + expect(updateStub.calledWith([{ Id: 'STAGE1', SourceCodeRepositoryBranchId: null }])).to.be.true; + expect(result.success).to.be.true; + expect(result.stageId).to.equal('STAGE1'); + expect(result.branchName).to.equal('main'); + expect(result.clearedStages).to.have.length(1); + }); + + it('cascades the clear to upstream stages', async () => { + updateStub.resolves([{ success: true }, { success: true }]); + + const result = await deleteStageBranch(connectionStub as unknown as Connection, 'INT', [ + { stageId: 'INT', stageName: 'Integration', branchName: 'integration' }, + { stageId: 'DEV', stageName: 'Development', branchName: 'dev' }, + ]); + + const updateArg = updateStub.firstCall.args[0] as Array<{ Id: string; SourceCodeRepositoryBranchId: null }>; + expect(updateArg.map((u) => u.Id)).to.deep.equal(['INT', 'DEV']); + expect(updateArg.every((u) => u.SourceCodeRepositoryBranchId === null)).to.be.true; + expect(result.success).to.be.true; + expect(result.branchName).to.equal('integration'); + expect(result.clearedStages).to.have.length(2); + }); + + it('returns failure when an update fails', async () => { + updateStub.resolves([{ success: false, errors: [{ message: 'record locked' }] }]); + + const result = await deleteStageBranch(connectionStub as unknown as Connection, 'STAGE1', [ + { stageId: 'STAGE1', stageName: 'Production', branchName: 'main' }, + ]); + + expect(result.success).to.be.false; + expect(result.error).to.contain('record locked'); + }); + + it('handles a single (non-array) update result', async () => { + updateStub.resolves({ success: true }); + + const result = await deleteStageBranch(connectionStub as unknown as Connection, 'STAGE1', [ + { stageId: 'STAGE1', stageName: 'Production', branchName: 'main' }, + ]); + + expect(result.success).to.be.true; + }); +}); diff --git a/test/utils/pipelineUtils.test.ts b/test/utils/pipelineUtils.test.ts index 41d2038c..3ec01a90 100644 --- a/test/utils/pipelineUtils.test.ts +++ b/test/utils/pipelineUtils.test.ts @@ -24,6 +24,7 @@ import { resolveTargetStageId, findStageById, getBranchNameFromStage, + computeUpstreamStageIds, } from '../../src/utils/pipelineUtils.js'; import { PipelineStageRecord } from '../../src/utils/types.js'; @@ -149,4 +150,40 @@ describe('pipelineUtils', () => { expect(getBranchNameFromStage(stage)).to.be.undefined; }); }); + + describe('computeUpstreamStageIds', () => { + // Dev -> Int -> Prod + const LINEAR: PipelineStageRecord[] = [ + { Id: 'DEV', NextStageId: 'INT' }, + { Id: 'INT', NextStageId: 'PROD' }, + { Id: 'PROD', NextStageId: null }, + ]; + + it('returns all upstream stages for a middle stage', () => { + expect(computeUpstreamStageIds(LINEAR, 'INT')).to.deep.equal(['DEV']); + }); + + it('returns the whole chain upstream of the last stage, nearest first', () => { + expect(computeUpstreamStageIds(LINEAR, 'PROD')).to.deep.equal(['INT', 'DEV']); + }); + + it('returns empty array for the first stage', () => { + expect(computeUpstreamStageIds(LINEAR, 'DEV')).to.deep.equal([]); + }); + + it('handles branching pipelines where two stages feed one', () => { + // A -> C, B -> C + const branching: PipelineStageRecord[] = [ + { Id: 'A', NextStageId: 'C' }, + { Id: 'B', NextStageId: 'C' }, + { Id: 'C', NextStageId: null }, + ]; + expect(computeUpstreamStageIds(branching, 'C')).to.have.members(['A', 'B']); + expect(computeUpstreamStageIds(branching, 'A')).to.deep.equal([]); + }); + + it('returns empty array when stage is not found', () => { + expect(computeUpstreamStageIds(LINEAR, 'MISSING')).to.deep.equal([]); + }); + }); }); diff --git a/test/utils/prepareWorkItem.test.ts b/test/utils/prepareWorkItem.test.ts index d324eb28..18621b35 100644 --- a/test/utils/prepareWorkItem.test.ts +++ b/test/utils/prepareWorkItem.test.ts @@ -59,6 +59,23 @@ describe('prepareWorkItem', () => { expect(body.targetStageId).to.equal('05S000000000002'); }); + it('omits sourceStageId from request body when not provided', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ success: true, requestToken: 'tok' }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + await prepareWorkItem({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemId: '0Wx000000000001', + targetStageId: '05S000000000002', + }); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body).to.not.have.property('sourceStageId'); + expect(body.selectedWorkItemId).to.equal('0Wx000000000001'); + expect(body.targetStageId).to.equal('05S000000000002'); + }); + it('returns failure with error code and message', async () => { (connectionStub.request as sinon.SinonStub).resolves({ success: false, diff --git a/test/utils/promoteStage.test.ts b/test/utils/promoteStage.test.ts index d9acc994..265f6166 100644 --- a/test/utils/promoteStage.test.ts +++ b/test/utils/promoteStage.test.ts @@ -92,6 +92,30 @@ describe('promoteStage utilities', () => { expect(body.deployOptions).to.deep.equal({ testLevel: 'RunLocalTests', isFullDeploy: true, runTests: ['MyTest'] }); }); + it('passes 18-char IDs through unchanged in the request body', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + requestId: 'r', + status: 'SUBMITTED', + message: '', + promotedWorkitemIds: [], + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + await promoteStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + workItemIds: ['1fkWt000000hGr7IAE'], + targetStageId: '1QVWt000000G3huOAC', + }); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string) as Record< + string, + unknown + >; + expect(body.workitemIds).to.deep.equal(['1fkWt000000hGr7IAE']); + expect(body.targetStageId).to.equal('1QVWt000000G3huOAC'); + }); + it('propagates API errors', async () => { (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); diff --git a/test/utils/promotionUtils.test.ts b/test/utils/promotionUtils.test.ts new file mode 100644 index 00000000..565a9f70 --- /dev/null +++ b/test/utils/promotionUtils.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 { expect } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { validatePromotion, hasSharedComponents } from '../../src/utils/promotionUtils.js'; +import { normalizeSalesforceId } from '../../src/utils/soqlUtils.js'; + +describe('normalizeSalesforceId', () => { + it('returns a 15-char ID unchanged', () => { + expect(normalizeSalesforceId('1fkxx0000000001')).to.equal('1fkxx0000000001'); + }); + + it('trims an 18-char ID to 15 chars', () => { + expect(normalizeSalesforceId('1fkWt000000hGr7IAE')).to.equal('1fkWt000000hGr7'); + }); +}); + +describe('hasSharedComponents', () => { + it('returns false when combineDetails is null', () => { + expect(hasSharedComponents(null)).to.be.false; + }); + + it('returns false when sharedComponentsList is missing', () => { + expect(hasSharedComponents({ parentWorkitemId: '1fk000000000001' })).to.be.false; + }); + + it('returns false when sharedComponentsList is empty', () => { + expect(hasSharedComponents({ sharedComponentsList: {} })).to.be.false; + }); + + it('returns true when sharedComponentsList has entries', () => { + expect(hasSharedComponents({ sharedComponentsList: { 'WI-000122,WI-000136': ['HelloMCP2:ApexClass'] } })).to.be + .true; + }); +}); + +describe('validatePromotion', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + }); + + afterEach(() => { + sinon.restore(); + }); + + const successResponse = { + success: true, + errorType: null, + errorDetails: null, + combineDetails: null, + }; + + it('omits checkCombineDetails from request body when false (default)', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion(connectionStub as unknown as Connection, 'PIPE001', ['1fkxx0000000001'], '1QVxx0000000003'); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body).to.not.have.property('checkCombineDetails'); + }); + + it('omits checkCombineDetails from request body when explicitly false', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkxx0000000001'], + '1QVxx0000000003', + false + ); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body).to.not.have.property('checkCombineDetails'); + }); + + it('includes checkCombineDetails: true in request body when explicitly true', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkxx0000000001'], + '1QVxx0000000003', + true + ); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body.checkCombineDetails).to.be.true; + }); + + it('sends correct endpoint and base payload', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkxx0000000001', '1fkxx0000000002'], + '1QVxx0000000003' + ); + + const call = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(call.method).to.equal('POST'); + expect(call.url).to.include('/connect/devops/pipelines/PIPE001/validatePromote'); + const body = JSON.parse(call.body as string); + expect(body.selectedWorkItemIds).to.deep.equal(['1fkxx0000000001', '1fkxx0000000002']); + expect(body.targetStageId).to.equal('1QVxx0000000003'); + expect(body.allWorkItemsInStage).to.be.false; + }); + + it('sends allWorkItemsInStage: true when explicitly set', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkxx0000000001'], + '1QVxx0000000003', + false, + true + ); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body.allWorkItemsInStage).to.be.true; + }); + + it('trims 18-char IDs to 15 chars in the request body', async () => { + (connectionStub.request as sinon.SinonStub).resolves(successResponse); + + await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkWt000000hGr7IAE'], + '1QVWt000000G3huOAC' + ); + + const body = JSON.parse((connectionStub.request as sinon.SinonStub).firstCall.args[0].body as string); + expect(body.selectedWorkItemIds).to.deep.equal(['1fkWt000000hGr7']); + expect(body.targetStageId).to.equal('1QVWt000000G3hu'); + }); + + it('returns mapped result from API response', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: false, + errorType: 'COMBINE_REQUIRED', + errorDetails: 'Work items must be combined', + combineDetails: { items: ['1fkxx0000000001'] }, + }); + + const result = await validatePromotion( + connectionStub as unknown as Connection, + 'PIPE001', + ['1fkxx0000000001'], + '1QVxx0000000003', + true + ); + + expect(result.success).to.be.false; + expect(result.errorType).to.equal('COMBINE_REQUIRED'); + expect(result.errorDetails).to.equal('Work items must be combined'); + expect(result.combineDetails).to.deep.equal({ items: ['1fkxx0000000001'] }); + }); +});