diff --git a/command-snapshot.json b/command-snapshot.json index bbbc58b..ae93aab 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,22 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:promotion:validate", + "flagAliases": [], + "flagChars": ["i", "o", "t"], + "flags": [ + "api-version", + "check-combine-details", + "flags-dir", + "json", + "target-org", + "target-stage-id", + "work-item-id" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:request:status", diff --git a/messages/devops.pipeline.stage.update.md b/messages/devops.pipeline.stage.update.md new file mode 100644 index 0000000..3799277 --- /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 9367e4e..277f87d 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.validate.md b/messages/devops.promotion.validate.md new file mode 100644 index 0000000..980cf72 --- /dev/null +++ b/messages/devops.promotion.validate.md @@ -0,0 +1,41 @@ +# 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. Use --check-combine-details to also check for shared components that require combine resolution. + +# 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. + +# flags.check-combine-details.summary + +Check for shared components requiring combine resolution. Use this when custom promotion with the combine feature is enabled. + +# 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 with combine details check. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --target-stage-id 1QV000000000001 --work-item-id 1fk000000000001 --work-item-id 1fk000000000002 --check-combine-details + +# 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 diff --git a/schemas/devops-pipeline-stage-update.json b/schemas/devops-pipeline-stage-update.json new file mode 100644 index 0000000..9e87e76 --- /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 393f8f3..6399159 100644 --- a/schemas/devops-promote.json +++ b/schemas/devops-promote.json @@ -19,10 +19,24 @@ "items": { "type": "string" } + }, + "combineDetails": { + "anyOf": [ + { + "$ref": "#/definitions/CombineDetails" + }, + { + "type": "null" + } + ] } }, "required": ["requestId", "status", "message", "promotedWorkitemIds"], "additionalProperties": false + }, + "CombineDetails": { + "type": "object", + "additionalProperties": {} } } } diff --git a/schemas/devops-promotion-validate.json b/schemas/devops-promotion-validate.json new file mode 100644 index 0000000..6e0fa3e --- /dev/null +++ b/schemas/devops-promotion-validate.json @@ -0,0 +1,36 @@ +{ + "$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" + } + ] + } + }, + "required": ["success", "errorType", "errorDetails", "combineDetails"], + "additionalProperties": false + }, + "CombineDetails": { + "type": "object", + "additionalProperties": {} + } + } +} diff --git a/src/commands/devops/pipeline/stage/update.ts b/src/commands/devops/pipeline/stage/update.ts new file mode 100644 index 0000000..ce4b7c7 --- /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 775e62b..97eac96 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 f9ad678..8f18ab4 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 0000000..5ada90e --- /dev/null +++ b/src/commands/devops/promotion/validate.ts @@ -0,0 +1,129 @@ +/* + * 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, +} 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; +}; + +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', + }), + 'check-combine-details': Flags.boolean({ + summary: messages.getMessage('flags.check-combine-details.summary'), + default: false, + }), + }; + + 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']; + const checkCombineDetails = flags['check-combine-details']; + + 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, checkCombineDetails); + } 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)); + } + + return { + success: result.success, + errorType: result.errorType, + errorDetails: result.errorDetails, + combineDetails: result.combineDetails, + }; + } +} diff --git a/src/commands/devops/work-item/prepare.ts b/src/commands/devops/work-item/prepare.ts index a548aa6..2af1df4 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/prepareWorkItem.ts b/src/utils/prepareWorkItem.ts index 3c526a1..c38f9ac 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 60% rename from src/utils/deployStage.ts rename to src/utils/promotionUtils.ts index fd5e88c..6feeb31 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,22 @@ type ValidateDeployResponse = { errorDetails?: string; }; +export type CombineDetails = Record; + +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 +103,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 f154d18..64998bf 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 0000000..a466184 --- /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 4591fd2..6c7e151 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 a4da035..b2e5b9f 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 0000000..39aaa2d --- /dev/null +++ b/test/commands/devops/promotion/validate.test.ts @@ -0,0 +1,324 @@ +/* + * 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; + }); + + 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('passes checkCombineDetails=true when flag is set', 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', + '--check-combine-details', + ]); + + expect(validatePromotionStub.firstCall.args[4]).to.be.true; + }); + + 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', + '--check-combine-details', + ]); + + expect(ctx.stdout).to.contain('Combine Details'); + expect(ctx.stdout).to.contain('ComponentA'); + expect(result.combineDetails).to.deep.equal({ sharedComponents: ['ComponentA'] }); + }); + }); + + // ── 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/work-item/prepare.test.ts b/test/commands/devops/work-item/prepare.test.ts index d585a89..68e0dc9 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 faa93f3..56f2871 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/prepareWorkItem.test.ts b/test/utils/prepareWorkItem.test.ts index d324eb2..18621b3 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 d9acc99..265f616 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 0000000..6630d97 --- /dev/null +++ b/test/utils/promotionUtils.test.ts @@ -0,0 +1,162 @@ +/* + * 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 } 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('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'] }); + }); +});