From 916fc5f8fa0291fc9b884e6c0527c4bde822032d Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Wed, 1 Jul 2026 01:14:45 +0530 Subject: [PATCH 1/6] feat: stage add-branch --- command-snapshot.json | 17 + messages/devops.stage.add-branch.md | 45 +++ schemas/devops-stage-add__branch.json | 34 ++ src/commands/devops/stage/add-branch.ts | 118 ++++++ src/utils/addStageBranch.ts | 79 ++++ test/commands/devops/stage/add-branch.test.ts | 377 ++++++++++++++++++ test/utils/addStageBranch.test.ts | 128 ++++++ 7 files changed, 798 insertions(+) create mode 100644 messages/devops.stage.add-branch.md create mode 100644 schemas/devops-stage-add__branch.json create mode 100644 src/commands/devops/stage/add-branch.ts create mode 100644 src/utils/addStageBranch.ts create mode 100644 test/commands/devops/stage/add-branch.test.ts create mode 100644 test/utils/addStageBranch.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index 154c263..5bd2e9d 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -43,6 +43,23 @@ "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:stage:add-branch", + "flagAliases": [], + "flagChars": ["b", "o"], + "flags": [ + "api-version", + "branch-name", + "create-vcs-branch", + "flags-dir", + "json", + "pipeline-id", + "stage-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:project:create", diff --git a/messages/devops.stage.add-branch.md b/messages/devops.stage.add-branch.md new file mode 100644 index 0000000..0865c35 --- /dev/null +++ b/messages/devops.stage.add-branch.md @@ -0,0 +1,45 @@ +# summary + +Associate a source code repository branch with a pipeline stage. + +# description + +Associates a source code repository branch with a pipeline stage. By default, the branch must already exist in the repository linked to the pipeline. Use `--create-vcs-branch` to create a new branch in the remote repository if it doesn't exist. Each stage can have at most one branch; if the stage already has a branch, the command replaces it. + +# flags.pipeline-id.summary + +ID of the pipeline that contains the stage. + +# flags.stage-id.summary + +ID of the pipeline stage to associate the branch with. + +# flags.branch-name.summary + +Name of the repository branch to assign to the stage. + +# flags.create-vcs-branch.summary + +Create the branch in the remote repository if it doesn't already exist. + +# examples + +- Attach an existing branch to a stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --branch-name main + +- Create a new branch in the remote repository and attach it to a stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --branch-name integration --create-vcs-branch + +# error.StageNotFound + +Pipeline stage "%s" doesn't exist in pipeline "%s". Check the stage ID and try again. + +# error.NextStageNoBranch + +You must set up a branch on stage "%s" before configuring stage "%s". Branches must be configured from right to left (starting from the last stage in the pipeline). + +# error.BranchAttachFailed + +Failed to associate branch with stage: %s diff --git a/schemas/devops-stage-add__branch.json b/schemas/devops-stage-add__branch.json new file mode 100644 index 0000000..fbd3941 --- /dev/null +++ b/schemas/devops-stage-add__branch.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AddStageBranchResult", + "definitions": { + "AddStageBranchResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "branchName": { + "type": "string" + }, + "branchCreated": { + "type": "boolean" + }, + "repoBranchId": { + "type": "string" + }, + "pipelineId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success", "stageId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/stage/add-branch.ts b/src/commands/devops/stage/add-branch.ts new file mode 100644 index 0000000..d8c2ccf --- /dev/null +++ b/src/commands/devops/stage/add-branch.ts @@ -0,0 +1,118 @@ +/* + * 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 { addStageBranch, AddStageBranchResult } from '../../../utils/addStageBranch.js'; +import { 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.add-branch'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsStageAddBranch 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, + }), + 'branch-name': Flags.string({ + summary: messages.getMessage('flags.branch-name.summary'), + required: true, + char: 'b', + }), + 'create-vcs-branch': Flags.boolean({ + summary: messages.getMessage('flags.create-vcs-branch.summary'), + default: false, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsStageAddBranch); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const stageId = flags['stage-id']; + + 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])); + } + + // Enforce right-to-left branch setup order: the next stage (to the right) + // must already have a branch before this stage can be configured. + if (targetStage.NextStageId) { + const nextStage = stages.find((s) => s.Id === targetStage.NextStageId); + if (nextStage && !nextStage.SourceCodeRepositoryBranch?.Name) { + this.error(messages.getMessage('error.NextStageNoBranch', [nextStage.Name ?? nextStage.Id, stageId])); + } + } + + let result: AddStageBranchResult; + try { + result = await addStageBranch({ + connection, + pipelineId, + stageId, + branchName: flags['branch-name'], + createVcsBranch: flags['create-vcs-branch'], + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + if (result.success) { + const action = result.branchCreated ? 'Created branch and associated it' : 'Successfully associated branch'; + this.log(`${action} with the stage.`); + this.log(` Stage ID: ${stageId}`); + this.log(` Branch: ${result.branchName ?? ''}${result.branchCreated ? ' (newly created)' : ''}`); + this.log(` Repo Branch ID: ${result.repoBranchId ?? ''}`); + this.log(` Pipeline ID: ${pipelineId}`); + } else { + this.error(messages.getMessage('error.BranchAttachFailed', [result.error ?? ''])); + } + + return result; + } +} diff --git a/src/utils/addStageBranch.ts b/src/utils/addStageBranch.ts new file mode 100644 index 0000000..e03f505 --- /dev/null +++ b/src/utils/addStageBranch.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 { Connection } from '@salesforce/core'; + +export type AddStageBranchParams = { + connection: Connection; + pipelineId: string; + stageId: string; + branchName: string; + createVcsBranch: boolean; +}; + +export type AddStageBranchResult = { + success: boolean; + stageId: string; + branchName?: string; + branchCreated?: boolean; + repoBranchId?: string; + pipelineId?: string; + error?: string; +}; + +type PipelineStagePatchResponse = { + id: string; + status: string; + message?: string; + repoBranchId?: string; +}; + +/** + * Associates a branch with a pipeline stage via the Connect API. + * PATCH /services/data/v{version}/connect/devops/pipelines/{pipelineId}/stages/{stageId} + */ +export async function addStageBranch(params: AddStageBranchParams): Promise { + const { connection, pipelineId, stageId, branchName, createVcsBranch } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/stages/${stageId}`; + + const data = await connection.request({ + method: 'PATCH', + url: path, + body: JSON.stringify({ + vcsBranch: branchName, + createVcsBranch: String(createVcsBranch), + }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (data.status === 'FAILED') { + return { + success: false, + stageId, + error: data.message ?? 'Failed to associate branch with stage', + }; + } + + return { + success: true, + stageId, + branchName, + branchCreated: createVcsBranch, + repoBranchId: data.repoBranchId, + pipelineId, + }; +} diff --git a/test/commands/devops/stage/add-branch.test.ts b/test/commands/devops/stage/add-branch.test.ts new file mode 100644 index 0000000..e422602 --- /dev/null +++ b/test/commands/devops/stage/add-branch.test.ts @@ -0,0 +1,377 @@ +/* + * 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 add-branch', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let AddBranchCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const addStageBranchStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/stage/add-branch.js', { + '../../../../src/utils/addStageBranch.js': { + addStageBranch: addStageBranchStub, + }, + '../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + AddBranchCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + addStageBranchStub.reset(); + fetchPipelineStagesStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('attach existing branch', () => { + test + .stdout() + .stderr() + .it('attaches branch and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000001', + branchName: 'main', + branchCreated: false, + repoBranchId: '0Xq000000000001', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'main', + ]); + + expect(ctx.stdout).to.contain('Successfully associated branch with the stage.'); + expect(ctx.stdout).to.contain('0Xp000000000001'); + expect(ctx.stdout).to.contain('main'); + expect(ctx.stdout).to.contain('0Xo000000000001'); + }); + }); + + describe('create and attach new branch', () => { + test + .stdout() + .stderr() + .it('creates branch and logs success with newly created indicator', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000002', Name: 'Integration' }]); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000002', + branchName: 'integration', + branchCreated: true, + repoBranchId: '0Xq000000000002', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000002', + '--branch-name', + 'integration', + '--create-vcs-branch', + ]); + + expect(ctx.stdout).to.contain('Created branch and associated it with the stage.'); + expect(ctx.stdout).to.contain('integration'); + expect(ctx.stdout).to.contain('newly created'); + }); + }); + + describe('right-to-left ordering enforcement', () => { + test + .stdout() + .stderr() + .it('blocks branch setup when next stage has no branch configured', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + // A -> B -> Production; B has no branch, trying to set A + fetchPipelineStagesStub.resolves([ + { + Id: '0Xp000000000001', + Name: 'Development', + NextStageId: '0Xp000000000002', + SourceCodeRepositoryBranch: null, + }, + { + Id: '0Xp000000000002', + Name: 'Integration', + NextStageId: '0Xp000000000003', + SourceCodeRepositoryBranch: null, + }, + { + Id: '0Xp000000000003', + Name: 'Production', + NextStageId: null, + SourceCodeRepositoryBranch: { Name: 'main' }, + }, + ]); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'dev', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('must set up a branch on stage "Integration"'); + }); + + test + .stdout() + .stderr() + .it('allows branch setup when next stage already has a branch', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([ + { + Id: '0Xp000000000001', + Name: 'Development', + NextStageId: '0Xp000000000002', + SourceCodeRepositoryBranch: null, + }, + { + Id: '0Xp000000000002', + Name: 'Integration', + NextStageId: '0Xp000000000003', + SourceCodeRepositoryBranch: { Name: 'integration' }, + }, + { + Id: '0Xp000000000003', + Name: 'Production', + NextStageId: null, + SourceCodeRepositoryBranch: { Name: 'main' }, + }, + ]); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000001', + branchName: 'dev', + branchCreated: false, + repoBranchId: '0Xq000000000001', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'dev', + ]); + + expect(ctx.stdout).to.contain('Successfully associated branch with the stage.'); + }); + + test + .stdout() + .stderr() + .it('allows branch setup on the last stage (no NextStageId)', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([ + { + Id: '0Xp000000000001', + Name: 'Development', + NextStageId: '0Xp000000000003', + SourceCodeRepositoryBranch: null, + }, + { Id: '0Xp000000000003', Name: 'Production', NextStageId: null, SourceCodeRepositoryBranch: null }, + ]); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000003', + branchName: 'main', + branchCreated: false, + repoBranchId: '0Xq000000000003', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000003', + '--branch-name', + 'main', + ]); + + expect(ctx.stdout).to.contain('Successfully associated branch with the stage.'); + }); + }); + + 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); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000099', + '--branch-name', + 'main', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("doesn't exist in pipeline"); + }); + }); + + describe('API returns FAILED status', () => { + test + .stdout() + .stderr() + .it('shows error message from API', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageBranchStub.resolves({ + success: false, + stageId: '0Xp000000000001', + error: 'Branch "nonexistent" does not exist in the repository', + }); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'nonexistent', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Failed to associate branch with stage'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'main', + ]); + } 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 addStageBranch', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageBranchStub.rejects(new Error('Network error')); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'main', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/utils/addStageBranch.test.ts b/test/utils/addStageBranch.test.ts new file mode 100644 index 0000000..09ed4c1 --- /dev/null +++ b/test/utils/addStageBranch.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from '@oclif/test'; +import sinon from 'sinon'; +import { Connection } from '@salesforce/core'; +import { addStageBranch } from '../../src/utils/addStageBranch.js'; + +describe('addStageBranch utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls PATCH endpoint with correct body when attaching existing branch', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0Xp000000000001', + status: 'SUCCESS', + message: '', + repoBranchId: '0Xq000000000001', + }); + + const result = await addStageBranch({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + branchName: 'main', + createVcsBranch: false, + }); + + expect(result.success).to.be.true; + expect(result.stageId).to.equal('0Xp000000000001'); + expect(result.branchName).to.equal('main'); + expect(result.branchCreated).to.be.false; + expect(result.repoBranchId).to.equal('0Xq000000000001'); + expect(result.pipelineId).to.equal('0Xo000000000001'); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/pipelines/0Xo000000000001/stages/0Xp000000000001'); + expect(callArgs.method).to.equal('PATCH'); + + const body = JSON.parse(callArgs.body as string); + expect(body.vcsBranch).to.equal('main'); + expect(body.createVcsBranch).to.equal('false'); + }); + + it('sends createVcsBranch as "true" when creating a new branch', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0Xp000000000002', + status: 'SUCCESS', + message: '', + repoBranchId: '0Xq000000000002', + }); + + const result = await addStageBranch({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000002', + branchName: 'integration', + createVcsBranch: true, + }); + + expect(result.success).to.be.true; + expect(result.branchCreated).to.be.true; + expect(result.branchName).to.equal('integration'); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string); + expect(body.vcsBranch).to.equal('integration'); + expect(body.createVcsBranch).to.equal('true'); + }); + + it('returns error when API responds with FAILED status', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0Xp000000000001', + status: 'FAILED', + message: 'Branch "nonexistent" does not exist in the repository', + }); + + const result = await addStageBranch({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + branchName: 'nonexistent', + createVcsBranch: false, + }); + + expect(result.success).to.be.false; + expect(result.stageId).to.equal('0Xp000000000001'); + expect(result.error).to.contain('does not exist'); + }); + + it('propagates connection errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Network timeout')); + + try { + await addStageBranch({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + branchName: 'main', + createVcsBranch: false, + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network timeout'); + } + }); +}); From f76e6cf6d4962a89571272ff2fcb37730c980482 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Wed, 1 Jul 2026 02:51:46 +0530 Subject: [PATCH 2/6] feat: add-environment command --- command-snapshot.json | 18 + messages/devops.stage.add-environment.md | 71 ++++ schemas/devops-stage-add__environment.json | 44 +++ src/commands/devops/stage/add-environment.ts | 143 ++++++++ src/utils/addStageEnvironment.ts | 204 ++++++++++ .../devops/stage/add-environment.test.ts | 327 +++++++++++++++++ test/utils/addStageEnvironment.test.ts | 347 ++++++++++++++++++ 7 files changed, 1154 insertions(+) create mode 100644 messages/devops.stage.add-environment.md create mode 100644 schemas/devops-stage-add__environment.json create mode 100644 src/commands/devops/stage/add-environment.ts create mode 100644 src/utils/addStageEnvironment.ts create mode 100644 test/commands/devops/stage/add-environment.test.ts create mode 100644 test/utils/addStageEnvironment.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index 5bd2e9d..98155d8 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -60,6 +60,24 @@ ], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:stage:add-environment", + "flagAliases": [], + "flagChars": ["e", "o"], + "flags": [ + "api-version", + "environment-name", + "flags-dir", + "json", + "no-browser", + "org-type", + "pipeline-id", + "stage-id", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:project:create", diff --git a/messages/devops.stage.add-environment.md b/messages/devops.stage.add-environment.md new file mode 100644 index 0000000..5631756 --- /dev/null +++ b/messages/devops.stage.add-environment.md @@ -0,0 +1,71 @@ +# summary + +Create and associate a Salesforce environment with a pipeline stage. + +# description + +Creates a new environment and associates it with a pipeline stage. The command triggers an OAuth flow to authenticate the environment — a browser window opens automatically for you to log in. Once authenticated, the command validates the connection and prints the final environment details. + +Use --no-browser if you want to authenticate manually by opening the redirect URL yourself. + +# flags.pipeline-id.summary + +ID of the pipeline that contains the stage. + +# flags.stage-id.summary + +ID of the pipeline stage to associate the environment with. + +# flags.environment-name.summary + +Name of the environment to create and associate with the stage. + +# flags.org-type.summary + +Type of the Salesforce org. Valid values: Production, Sandbox. + +# flags.no-browser.summary + +Don't auto-open the browser for OAuth authentication. The redirect URL is printed for manual use. + +# examples + +- Create a production environment and attach it to a stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production + +- Create a sandbox environment and attach it to a stage. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --environment-name UAT_Sandbox --org-type Sandbox + +- Create an environment without opening the browser. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production --no-browser + +# info.BrowserOpened + +A browser window has been opened for authentication. Log in to the target org to complete the setup. + +# info.ManualAuth + +Open the following URL in your browser to authenticate the environment:\n%s + +# info.WaitingForAuth + +Waiting for authentication to complete... + +# info.Success + +Successfully created and authenticated the environment. + +# error.StageNotFound + +Pipeline stage "%s" doesn't exist in pipeline "%s". Check the stage ID and try again. + +# error.EnvironmentAttachFailed + +Failed to create environment for stage: %s + +# error.AuthTimeout + +Authentication timed out. The environment was created but not yet authenticated. Re-run the command or authenticate manually via the org's DevOps Center setup. diff --git a/schemas/devops-stage-add__environment.json b/schemas/devops-stage-add__environment.json new file mode 100644 index 0000000..21190bc --- /dev/null +++ b/schemas/devops-stage-add__environment.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AddStageEnvironmentResult", + "definitions": { + "AddStageEnvironmentResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "environmentId": { + "type": "string" + }, + "environmentName": { + "type": "string" + }, + "orgType": { + "type": "string", + "enum": ["Production", "Sandbox"] + }, + "pipelineId": { + "type": "string" + }, + "redirectUrl": { + "type": "string" + }, + "namedCredential": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success", "stageId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/stage/add-environment.ts b/src/commands/devops/stage/add-environment.ts new file mode 100644 index 0000000..c51106e --- /dev/null +++ b/src/commands/devops/stage/add-environment.ts @@ -0,0 +1,143 @@ +/* + * 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 { exec } from 'node:child_process'; +import { Messages, Org } from '@salesforce/core'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { addStageEnvironment, AddStageEnvironmentResult, OrgType } from '../../../utils/addStageEnvironment.js'; +import { fetchPipelineStages } from '../../../utils/pipelineUtils.js'; +import { PipelineStageRecord } from '../../../utils/types.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.stage.add-environment'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +function openUrl(url: string): void { + const platform = process.platform; + const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'; + exec(`${cmd} "${url}"`); +} + +function decodeRedirectUrl(url: string): string { + return url.replace(/&/g, '&'); +} + +export default class DevopsStageAddEnvironment 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, + }), + 'environment-name': Flags.string({ + summary: messages.getMessage('flags.environment-name.summary'), + required: true, + char: 'e', + }), + 'org-type': Flags.string({ + summary: messages.getMessage('flags.org-type.summary'), + required: true, + options: ['Production', 'Sandbox'], + }), + 'no-browser': Flags.boolean({ + summary: messages.getMessage('flags.no-browser.summary'), + default: false, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsStageAddEnvironment); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const stageId = flags['stage-id']; + const environmentName = flags['environment-name']; + const orgType = flags['org-type'] as OrgType; + const noBrowser = flags['no-browser']; + + 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; + } + + if (!stages.some((s) => s.Id === stageId)) { + this.error(messages.getMessage('error.StageNotFound', [stageId, pipelineId])); + } + + let result: AddStageEnvironmentResult; + try { + result = await addStageEnvironment({ + connection, + pipelineId, + stageId, + environmentName, + orgType, + onCreated: (data) => { + const url = decodeRedirectUrl(data.redirectUrl); + if (!noBrowser) { + openUrl(url); + this.log(messages.getMessage('info.BrowserOpened')); + } else { + this.log(messages.getMessage('info.ManualAuth', [url])); + } + this.spinner.start(messages.getMessage('info.WaitingForAuth')); + }, + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + if (errMsg.includes('timed out')) { + this.error(messages.getMessage('error.AuthTimeout')); + } + throw error; + } finally { + this.spinner.stop(); + } + + if (result.success) { + this.log(messages.getMessage('info.Success')); + this.log(` Stage ID: ${stageId}`); + this.log(` Environment ID: ${result.environmentId ?? ''}`); + this.log(` Environment Name: ${result.environmentName ?? ''}`); + this.log(` Org Type: ${orgType}`); + this.log(` Pipeline ID: ${pipelineId}`); + this.log(` Organization ID: ${result.organizationId ?? ''}`); + } else { + this.error(messages.getMessage('error.EnvironmentAttachFailed', [result.error ?? ''])); + } + + return result; + } +} diff --git a/src/utils/addStageEnvironment.ts b/src/utils/addStageEnvironment.ts new file mode 100644 index 0000000..807acdf --- /dev/null +++ b/src/utils/addStageEnvironment.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Connection } from '@salesforce/core'; + +export type OrgType = 'Production' | 'Sandbox'; + +const ORG_TYPE_API_MAP: Record = { + Production: 'PRODUCTION', + Sandbox: 'SANDBOX', +}; + +export const POLL_INTERVAL_MS = 3000; +export const POLL_TIMEOUT_MS = 5 * 60 * 1000; + +export type AddStageEnvironmentParams = { + connection: Connection; + pipelineId: string; + stageId: string; + environmentName: string; + orgType: OrgType; +}; + +export type AddStageEnvironmentResult = { + success: boolean; + stageId: string; + environmentId?: string; + environmentName?: string; + orgType?: OrgType; + pipelineId?: string; + redirectUrl?: string; + namedCredential?: string; + organizationId?: string; + error?: string; +}; + +type EnvironmentCreateResponse = { + id: string; + name: string; + redirectUrl: string; + namedCredential: string; + externalCredential: string; + pipelineId?: string; +}; + +export type EnvironmentGetResponse = { + id: string; + name: string; + organizationId?: string; + orgType?: string; + namedCredential?: string; + redirectUrl?: string; +}; + +type EnvironmentValidateResponse = { + id: string; + name: string; + organizationId?: string; + orgType?: string; + namedCredential?: string; +}; + +/** + * Creates an environment and associates it with a pipeline stage via the Connect API. + * POST /services/data/v{version}/connect/devops/environment + */ +export async function createEnvironment( + connection: Connection, + params: { pipelineId: string; stageId: string; environmentName: string; orgType: OrgType } +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/environment`; + + return connection.request({ + method: 'POST', + url: path, + body: JSON.stringify({ + envName: params.environmentName, + orgType: ORG_TYPE_API_MAP[params.orgType], + pipelineStageId: params.stageId, + pipelineId: params.pipelineId, + }), + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** + * Fetches the current state of an environment. + * GET /services/data/v{version}/connect/devops/environment/{environmentId} + */ +export async function getEnvironment(connection: Connection, environmentId: string): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/environment/${environmentId}`; + return connection.request({ method: 'GET', url: path }); +} + +/** + * Validates an authenticated environment by triggering server-side org extraction. + * PATCH /services/data/v{version}/connect/devops/environment/{environmentId} + */ +export async function validateEnvironment( + connection: Connection, + environmentId: string +): Promise { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/environment/${environmentId}`; + return connection.request({ + method: 'PATCH', + url: path, + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Polls until authentication is complete by attempting PATCH validation. + * The PATCH call triggers server-side org extraction; if the user has + * completed OAuth, it returns a response with organizationId populated. + * If auth isn't done yet, the PATCH throws — we catch and retry. + * Falls back to GET polling as a secondary check. + * Throws on timeout. + */ +// eslint-disable-next-line no-await-in-loop -- polling requires sequential awaits by design +export async function pollForAuthentication( + connection: Connection, + environmentId: string, + timeoutMs: number = POLL_TIMEOUT_MS, + intervalMs: number = POLL_INTERVAL_MS +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + // eslint-disable-next-line no-await-in-loop + const validated = await validateEnvironment(connection, environmentId); + if (validated.organizationId) { + return validated; + } + } catch { + // PATCH failed — auth likely not yet complete, continue polling + } + // eslint-disable-next-line no-await-in-loop + await sleep(intervalMs); + } + + throw new Error( + `Authentication timed out after ${timeoutMs / 1000} seconds. Re-run the command or authenticate manually.` + ); +} + +/** + * Full orchestration: create environment -> poll for auth -> validate. + * Returns the final result including organizationId. + * + * The caller is responsible for opening the browser at the returned redirectUrl + * and displaying progress to the user. Use `onCreated` to receive the redirect URL + * before polling begins. + */ +export async function addStageEnvironment( + params: AddStageEnvironmentParams & { + onCreated?: (data: { environmentId: string; redirectUrl: string }) => void; + pollTimeoutMs?: number; + pollIntervalMs?: number; + } +): Promise { + const { connection, pipelineId, stageId, environmentName, orgType, onCreated, pollTimeoutMs, pollIntervalMs } = + params; + + const createData = await createEnvironment(connection, { pipelineId, stageId, environmentName, orgType }); + + if (onCreated) { + onCreated({ environmentId: createData.id, redirectUrl: createData.redirectUrl }); + } + + const validated = await pollForAuthentication(connection, createData.id, pollTimeoutMs, pollIntervalMs); + + return { + success: true, + stageId, + environmentId: createData.id, + environmentName: createData.name, + orgType, + pipelineId, + redirectUrl: createData.redirectUrl, + namedCredential: createData.namedCredential ?? validated.namedCredential, + organizationId: validated.organizationId, + }; +} diff --git a/test/commands/devops/stage/add-environment.test.ts b/test/commands/devops/stage/add-environment.test.ts new file mode 100644 index 0000000..0ca0320 --- /dev/null +++ b/test/commands/devops/stage/add-environment.test.ts @@ -0,0 +1,327 @@ +/* + * 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 add-environment', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let AddEnvironmentCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const addStageEnvironmentStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/stage/add-environment.js', { + '../../../../src/utils/addStageEnvironment.js': { + addStageEnvironment: addStageEnvironmentStub, + }, + '../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + AddEnvironmentCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + addStageEnvironmentStub.reset(); + fetchPipelineStagesStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful environment creation with full auth flow', () => { + test + .stdout() + .stderr() + .it('creates environment, opens browser, and logs success with organizationId', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageEnvironmentStub.callsFake( + async (params: { onCreated?: (data: { environmentId: string; redirectUrl: string }) => void }) => { + if (params.onCreated) { + params.onCreated({ + environmentId: '0Hi000000000001', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?client_id=abc', + }); + } + return { + success: true, + stageId: '0Xp000000000001', + environmentId: '0Hi000000000001', + environmentName: 'Production_Org', + orgType: 'Production', + pipelineId: '0Xo000000000001', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?client_id=abc', + namedCredential: 'Production_Org_NC', + organizationId: '00D000000000001', + }; + } + ); + + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + ]); + + expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + expect(ctx.stdout).to.contain('0Xp000000000001'); + expect(ctx.stdout).to.contain('0Hi000000000001'); + expect(ctx.stdout).to.contain('Production_Org'); + expect(ctx.stdout).to.contain('00D000000000001'); + }); + }); + + describe('--no-browser flag', () => { + test + .stdout() + .stderr() + .it('prints redirect URL instead of opening browser when --no-browser is set', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageEnvironmentStub.callsFake( + async (params: { onCreated?: (data: { environmentId: string; redirectUrl: string }) => void }) => { + if (params.onCreated) { + params.onCreated({ + environmentId: '0Hi000000000001', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?client_id=abc', + }); + } + return { + success: true, + stageId: '0Xp000000000001', + environmentId: '0Hi000000000001', + environmentName: 'Production_Org', + orgType: 'Production', + pipelineId: '0Xo000000000001', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?client_id=abc', + namedCredential: 'Production_Org_NC', + organizationId: '00D000000000001', + }; + } + ); + + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + '--no-browser', + ]); + + expect(ctx.stdout).to.contain('Open the following URL'); + expect(ctx.stdout).to.contain('login.salesforce.com'); + expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + }); + }); + + describe('sandbox environment creation', () => { + test + .stdout() + .stderr() + .it('creates sandbox environment and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000002', Name: 'UAT' }]); + addStageEnvironmentStub.callsFake( + async (params: { onCreated?: (data: { environmentId: string; redirectUrl: string }) => void }) => { + if (params.onCreated) { + params.onCreated({ + environmentId: '0Hi000000000002', + redirectUrl: 'https://test.salesforce.com/services/oauth2/authorize?...', + }); + } + return { + success: true, + stageId: '0Xp000000000002', + environmentId: '0Hi000000000002', + environmentName: 'UAT_Sandbox', + orgType: 'Sandbox', + pipelineId: '0Xo000000000001', + redirectUrl: 'https://test.salesforce.com/services/oauth2/authorize?...', + namedCredential: 'UAT_Sandbox_NC', + organizationId: '00D000000000002', + }; + } + ); + + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000002', + '--environment-name', + 'UAT_Sandbox', + '--org-type', + 'Sandbox', + ]); + + expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + expect(ctx.stdout).to.contain('UAT_Sandbox'); + expect(ctx.stdout).to.contain('00D000000000002'); + }); + }); + + 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); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000099', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("doesn't exist in pipeline"); + }); + }); + + describe('authentication timeout', () => { + test + .stdout() + .stderr() + .it('shows timeout error when authentication does not complete', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageEnvironmentStub.rejects( + new Error('Authentication timed out after 300 seconds. Re-run the command or authenticate manually.') + ); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('timed out'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Sandbox', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows API errors', () => { + test + .stdout() + .stderr() + .it('rethrows errors from addStageEnvironment', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + addStageEnvironmentStub.rejects(new Error('Bad Request: Environment name already exists')); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Duplicate_Env', + '--org-type', + 'Production', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('already exists'); + } + }); + }); +}); diff --git a/test/utils/addStageEnvironment.test.ts b/test/utils/addStageEnvironment.test.ts new file mode 100644 index 0000000..bb24ca6 --- /dev/null +++ b/test/utils/addStageEnvironment.test.ts @@ -0,0 +1,347 @@ +/* + * 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 { + addStageEnvironment, + createEnvironment, + getEnvironment, + validateEnvironment, + pollForAuthentication, +} from '../../src/utils/addStageEnvironment.js'; + +describe('addStageEnvironment utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + let requestStub: sinon.SinonStub; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + requestStub = sinon.stub(); + connectionStub.request = requestStub as unknown as typeof connectionStub.request; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('createEnvironment', () => { + it('calls POST /connect/devops/environment with correct body for Production', async () => { + requestStub.resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?...', + namedCredential: 'Production_Org_NC', + externalCredential: 'Production_Org_EC', + }); + + const result = await createEnvironment(connectionStub as unknown as Connection, { + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + environmentName: 'Production_Org', + orgType: 'Production', + }); + + expect(result.id).to.equal('0Hi000000000001'); + expect(result.name).to.equal('Production_Org'); + expect(result.redirectUrl).to.contain('login.salesforce.com'); + expect(result.namedCredential).to.equal('Production_Org_NC'); + + const callArgs = requestStub.firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/environment'); + expect(callArgs.method).to.equal('POST'); + + const body = JSON.parse(callArgs.body as string); + expect(body.envName).to.equal('Production_Org'); + expect(body.orgType).to.equal('PRODUCTION'); + expect(body.pipelineStageId).to.equal('0Xp000000000001'); + expect(body.pipelineId).to.equal('0Xo000000000001'); + }); + + it('sends orgType as SANDBOX for Sandbox input', async () => { + requestStub.resolves({ + id: '0Hi000000000002', + name: 'UAT_Sandbox', + redirectUrl: 'https://test.salesforce.com/services/oauth2/authorize?...', + namedCredential: 'UAT_Sandbox_NC', + externalCredential: 'UAT_Sandbox_EC', + }); + + const result = await createEnvironment(connectionStub as unknown as Connection, { + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000002', + environmentName: 'UAT_Sandbox', + orgType: 'Sandbox', + }); + + expect(result.name).to.equal('UAT_Sandbox'); + + const callArgs = requestStub.firstCall.args[0]; + const body = JSON.parse(callArgs.body as string); + expect(body.orgType).to.equal('SANDBOX'); + }); + + it('propagates API errors', async () => { + requestStub.rejects(new Error('Bad Request: Environment name already exists')); + + try { + await createEnvironment(connectionStub as unknown as Connection, { + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + environmentName: 'Duplicate_Env', + orgType: 'Production', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('already exists'); + } + }); + }); + + describe('getEnvironment', () => { + it('calls GET /connect/devops/environment/{id}', async () => { + requestStub.resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + orgType: 'PRODUCTION', + }); + + const result = await getEnvironment(connectionStub as unknown as Connection, '0Hi000000000001'); + + expect(result.id).to.equal('0Hi000000000001'); + expect(result.organizationId).to.equal('00D000000000001'); + + const callArgs = requestStub.firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/environment/0Hi000000000001'); + expect(callArgs.method).to.equal('GET'); + }); + + it('returns undefined organizationId when not yet authenticated', async () => { + requestStub.resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + }); + + const result = await getEnvironment(connectionStub as unknown as Connection, '0Hi000000000001'); + expect(result.organizationId).to.be.undefined; + }); + }); + + describe('validateEnvironment', () => { + it('calls PATCH /connect/devops/environment/{id} with empty body', async () => { + requestStub.resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + orgType: 'PRODUCTION', + namedCredential: 'Production_Org_NC', + }); + + const result = await validateEnvironment(connectionStub as unknown as Connection, '0Hi000000000001'); + + expect(result.organizationId).to.equal('00D000000000001'); + expect(result.namedCredential).to.equal('Production_Org_NC'); + + const callArgs = requestStub.firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/environment/0Hi000000000001'); + expect(callArgs.method).to.equal('PATCH'); + expect(JSON.parse(callArgs.body as string)).to.deep.equal({}); + }); + + it('propagates PATCH errors', async () => { + requestStub.rejects(new Error('Validation failed: org not reachable')); + + try { + await validateEnvironment(connectionStub as unknown as Connection, '0Hi000000000001'); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('org not reachable'); + } + }); + }); + + describe('pollForAuthentication', () => { + it('resolves immediately when PATCH returns organizationId on first attempt', async () => { + requestStub.resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + namedCredential: 'Production_Org_NC', + }); + + const result = await pollForAuthentication( + connectionStub as unknown as Connection, + '0Hi000000000001', + 10_000, + 100 + ); + expect(result.organizationId).to.equal('00D000000000001'); + }); + + it('retries PATCH when it throws (auth not yet complete)', async () => { + requestStub.onFirstCall().rejects(new Error('Auth not complete')); + requestStub.onSecondCall().rejects(new Error('Auth not complete')); + requestStub.onThirdCall().resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + namedCredential: 'Production_Org_NC', + }); + + const result = await pollForAuthentication( + connectionStub as unknown as Connection, + '0Hi000000000001', + 10_000, + 50 + ); + expect(result.organizationId).to.equal('00D000000000001'); + expect(requestStub.callCount).to.equal(3); + }); + + it('retries when PATCH succeeds but organizationId is not yet populated', async () => { + requestStub.onFirstCall().resolves({ id: '0Hi000000000001', name: 'Production_Org' }); + requestStub.onSecondCall().resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + namedCredential: 'Production_Org_NC', + }); + + const result = await pollForAuthentication( + connectionStub as unknown as Connection, + '0Hi000000000001', + 10_000, + 50 + ); + expect(result.organizationId).to.equal('00D000000000001'); + expect(requestStub.callCount).to.equal(2); + }); + + it('throws on timeout when auth never completes', async () => { + requestStub.rejects(new Error('Auth not complete')); + + try { + await pollForAuthentication(connectionStub as unknown as Connection, '0Hi000000000001', 200, 50); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('timed out'); + } + }); + }); + + describe('addStageEnvironment (full orchestration)', () => { + it('creates, polls via PATCH, and returns full result', async () => { + // POST create + requestStub.onFirstCall().resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + redirectUrl: 'https://login.salesforce.com/services/oauth2/authorize?client_id=abc', + namedCredential: 'Production_Org_NC', + externalCredential: 'Production_Org_EC', + }); + + // PATCH poll - first attempt: auth not done yet (throws) + requestStub.onSecondCall().rejects(new Error('Auth not complete')); + + // PATCH poll - second attempt: auth complete, returns organizationId + requestStub.onThirdCall().resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + organizationId: '00D000000000001', + orgType: 'PRODUCTION', + namedCredential: 'Production_Org_NC', + }); + + const onCreated = sinon.stub(); + + const result = await addStageEnvironment({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + environmentName: 'Production_Org', + orgType: 'Production', + onCreated, + pollTimeoutMs: 10_000, + pollIntervalMs: 50, + }); + + expect(result.success).to.be.true; + expect(result.environmentId).to.equal('0Hi000000000001'); + expect(result.environmentName).to.equal('Production_Org'); + expect(result.organizationId).to.equal('00D000000000001'); + expect(result.redirectUrl).to.contain('login.salesforce.com'); + expect(result.namedCredential).to.equal('Production_Org_NC'); + + expect(onCreated.calledOnce).to.be.true; + expect(onCreated.firstCall.args[0].environmentId).to.equal('0Hi000000000001'); + expect(onCreated.firstCall.args[0].redirectUrl).to.contain('login.salesforce.com'); + }); + + it('throws timeout error when auth never completes', async () => { + // POST create succeeds + requestStub.onFirstCall().resolves({ + id: '0Hi000000000001', + name: 'Production_Org', + redirectUrl: 'https://login.salesforce.com/...', + namedCredential: 'Production_Org_NC', + externalCredential: 'Production_Org_EC', + }); + + // PATCH poll - always throws (auth never completes) + requestStub.onSecondCall().rejects(new Error('Auth not complete')); + requestStub.onThirdCall().rejects(new Error('Auth not complete')); + requestStub.onCall(3).rejects(new Error('Auth not complete')); + requestStub.onCall(4).rejects(new Error('Auth not complete')); + requestStub.onCall(5).rejects(new Error('Auth not complete')); + requestStub.onCall(6).rejects(new Error('Auth not complete')); + + try { + await addStageEnvironment({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + environmentName: 'Production_Org', + orgType: 'Production', + pollTimeoutMs: 200, + pollIntervalMs: 50, + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('timed out'); + } + }); + + it('propagates connection errors during create', async () => { + requestStub.rejects(new Error('Network timeout')); + + try { + await addStageEnvironment({ + connection: connectionStub as unknown as Connection, + pipelineId: '0Xo000000000001', + stageId: '0Xp000000000001', + environmentName: 'Production_Org', + orgType: 'Sandbox', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network timeout'); + } + }); + }); +}); From 15c0ce831718dd825859bf96137e3e5702e84f4d Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Wed, 1 Jul 2026 02:58:55 +0530 Subject: [PATCH 3/6] fix: test --- schemas/devops-stage-add__environment.json | 7 +++++-- test/commands/devops/stage/add-environment.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/schemas/devops-stage-add__environment.json b/schemas/devops-stage-add__environment.json index 21190bc..d12b684 100644 --- a/schemas/devops-stage-add__environment.json +++ b/schemas/devops-stage-add__environment.json @@ -18,8 +18,7 @@ "type": "string" }, "orgType": { - "type": "string", - "enum": ["Production", "Sandbox"] + "$ref": "#/definitions/OrgType" }, "pipelineId": { "type": "string" @@ -39,6 +38,10 @@ }, "required": ["success", "stageId"], "additionalProperties": false + }, + "OrgType": { + "type": "string", + "enum": ["Production", "Sandbox"] } } } diff --git a/test/commands/devops/stage/add-environment.test.ts b/test/commands/devops/stage/add-environment.test.ts index 0ca0320..3905931 100644 --- a/test/commands/devops/stage/add-environment.test.ts +++ b/test/commands/devops/stage/add-environment.test.ts @@ -27,6 +27,7 @@ describe('devops stage add-environment', () => { const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; const addStageEnvironmentStub = sinon.stub(); const fetchPipelineStagesStub = sinon.stub(); + const execStub = sinon.stub(); before(async () => { const mod = await esmock('../../../../src/commands/devops/stage/add-environment.js', { @@ -36,6 +37,9 @@ describe('devops stage add-environment', () => { '../../../../src/utils/pipelineUtils.js': { fetchPipelineStages: fetchPipelineStagesStub, }, + 'node:child_process': { + exec: execStub, + }, }); AddEnvironmentCommand = mod.default; }); @@ -44,6 +48,7 @@ describe('devops stage add-environment', () => { sandbox = sinon.createSandbox(); addStageEnvironmentStub.reset(); fetchPipelineStagesStub.reset(); + execStub.reset(); }); afterEach(() => { From d13ed634a8fab80c801ff9079b4b260b9d7af522 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Wed, 1 Jul 2026 13:03:47 +0530 Subject: [PATCH 4/6] fix: clean up flag definitions and example formatting --- messages/devops.stage.add-branch.md | 4 ++-- messages/devops.stage.add-environment.md | 6 +++--- src/commands/devops/stage/add-branch.ts | 2 -- src/commands/devops/stage/add-environment.ts | 2 -- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/messages/devops.stage.add-branch.md b/messages/devops.stage.add-branch.md index 0865c35..a7c69a2 100644 --- a/messages/devops.stage.add-branch.md +++ b/messages/devops.stage.add-branch.md @@ -24,11 +24,11 @@ Create the branch in the remote repository if it doesn't already exist. # examples -- Attach an existing branch to a stage. +- Attach an existing branch to a stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --branch-name main -- Create a new branch in the remote repository and attach it to a stage. +- Create a new branch in the remote repository and attach it to a stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --branch-name integration --create-vcs-branch diff --git a/messages/devops.stage.add-environment.md b/messages/devops.stage.add-environment.md index 5631756..9efb19e 100644 --- a/messages/devops.stage.add-environment.md +++ b/messages/devops.stage.add-environment.md @@ -30,15 +30,15 @@ Don't auto-open the browser for OAuth authentication. The redirect URL is printe # examples -- Create a production environment and attach it to a stage. +- Create a production environment and attach it to a stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production -- Create a sandbox environment and attach it to a stage. +- Create a sandbox environment and attach it to a stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --environment-name UAT_Sandbox --org-type Sandbox -- Create an environment without opening the browser. +- Create an environment without opening the browser: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production --no-browser diff --git a/src/commands/devops/stage/add-branch.ts b/src/commands/devops/stage/add-branch.ts index d8c2ccf..71e2b08 100644 --- a/src/commands/devops/stage/add-branch.ts +++ b/src/commands/devops/stage/add-branch.ts @@ -35,12 +35,10 @@ export default class DevopsStageAddBranch extends SfCommand Date: Thu, 2 Jul 2026 00:46:37 +0530 Subject: [PATCH 5/6] chore: review fixes --- bin/dev.js | 0 bin/run.js | 0 messages/devops.stage.add-branch.md | 9 +++++---- messages/devops.stage.add-environment.md | 24 +++++++----------------- 4 files changed, 12 insertions(+), 21 deletions(-) mode change 100644 => 100755 bin/dev.js mode change 100644 => 100755 bin/run.js diff --git a/bin/dev.js b/bin/dev.js old mode 100644 new mode 100755 diff --git a/bin/run.js b/bin/run.js old mode 100644 new mode 100755 diff --git a/messages/devops.stage.add-branch.md b/messages/devops.stage.add-branch.md index a7c69a2..7c8c214 100644 --- a/messages/devops.stage.add-branch.md +++ b/messages/devops.stage.add-branch.md @@ -1,10 +1,11 @@ # summary -Associate a source code repository branch with a pipeline stage. +Add a source code repository branch to a pipeline stage. # description -Associates a source code repository branch with a pipeline stage. By default, the branch must already exist in the repository linked to the pipeline. Use `--create-vcs-branch` to create a new branch in the remote repository if it doesn't exist. Each stage can have at most one branch; if the stage already has a branch, the command replaces it. +By default, the branch must exist in the repository. Use --create-vcs-branch to create a branch if it doesn't exist. +Each pipeline stage supports only one branch. Adding a branch replaces any existing branch linked to the pipeline stage. # flags.pipeline-id.summary @@ -24,11 +25,11 @@ Create the branch in the remote repository if it doesn't already exist. # examples -- Attach an existing branch to a stage: +- Add an existing branch to a stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --branch-name main -- Create a new branch in the remote repository and attach it to a stage: +- Create and add a branch to a pipeline stage: <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --branch-name integration --create-vcs-branch diff --git a/messages/devops.stage.add-environment.md b/messages/devops.stage.add-environment.md index 9efb19e..89e2c92 100644 --- a/messages/devops.stage.add-environment.md +++ b/messages/devops.stage.add-environment.md @@ -1,12 +1,10 @@ # summary -Create and associate a Salesforce environment with a pipeline stage. +Add a Salesforce environment to a pipeline stage. # description -Creates a new environment and associates it with a pipeline stage. The command triggers an OAuth flow to authenticate the environment — a browser window opens automatically for you to log in. Once authenticated, the command validates the connection and prints the final environment details. - -Use --no-browser if you want to authenticate manually by opening the redirect URL yourself. +This command triggers an OAuth flow to authenticate the environment. A browser window opens automatically for you to log in. # flags.pipeline-id.summary @@ -14,11 +12,11 @@ ID of the pipeline that contains the stage. # flags.stage-id.summary -ID of the pipeline stage to associate the environment with. +ID of the pipeline stage. # flags.environment-name.summary -Name of the environment to create and associate with the stage. +Name of the environment. # flags.org-type.summary @@ -30,17 +28,9 @@ Don't auto-open the browser for OAuth authentication. The redirect URL is printe # examples -- Create a production environment and attach it to a stage: - - <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production - -- Create a sandbox environment and attach it to a stage: - - <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000002 --environment-name UAT_Sandbox --org-type Sandbox - -- Create an environment without opening the browser: +- Add a production environment to a stage using its ID: - <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0Xo000000000001 --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production --no-browser + <%= config.bin %> <%= command.id %> --target-org my-devops-org --stage-id 0Xp000000000001 --environment-name Production_Org --org-type Production # info.BrowserOpened @@ -56,7 +46,7 @@ Waiting for authentication to complete... # info.Success -Successfully created and authenticated the environment. +Successfully added environment to the stage. # error.StageNotFound From 14703b62045ca80f61fa6617960bb4290662657e Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Thu, 2 Jul 2026 00:54:15 +0530 Subject: [PATCH 6/6] fix: test --- command-snapshot.json | 48 +++++++++---------- src/commands/devops/stage/add-branch.ts | 2 + src/commands/devops/stage/add-environment.ts | 2 + .../devops/stage/add-environment.test.ts | 6 +-- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/command-snapshot.json b/command-snapshot.json index 98155d8..73279d1 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -43,6 +43,30 @@ "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "pipeline-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:project:create", + "flagAliases": [], + "flagChars": ["d", "n", "o"], + "flags": ["api-version", "description", "flags-dir", "json", "name", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:project:list", + "flagAliases": [], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:pull-request:create", + "flagAliases": [], + "flagChars": ["n", "o", "w"], + "flags": ["api-version", "body", "flags-dir", "json", "target-org", "title", "work-item-id", "work-item-name"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:stage:add-branch", @@ -78,30 +102,6 @@ ], "plugin": "@salesforce/plugin-devops-center" }, - { - "alias": [], - "command": "devops:project:create", - "flagAliases": [], - "flagChars": ["d", "n", "o"], - "flags": ["api-version", "description", "flags-dir", "json", "name", "target-org"], - "plugin": "@salesforce/plugin-devops-center" - }, - { - "alias": [], - "command": "devops:project:list", - "flagAliases": [], - "flagChars": ["o"], - "flags": ["api-version", "flags-dir", "json", "target-org"], - "plugin": "@salesforce/plugin-devops-center" - }, - { - "alias": [], - "command": "devops:pull-request:create", - "flagAliases": [], - "flagChars": ["n", "o", "w"], - "flags": ["api-version", "body", "flags-dir", "json", "target-org", "title", "work-item-id", "work-item-name"], - "plugin": "@salesforce/plugin-devops-center" - }, { "alias": [], "command": "devops:work-item:create", diff --git a/src/commands/devops/stage/add-branch.ts b/src/commands/devops/stage/add-branch.ts index 71e2b08..d8c2ccf 100644 --- a/src/commands/devops/stage/add-branch.ts +++ b/src/commands/devops/stage/add-branch.ts @@ -35,10 +35,12 @@ export default class DevopsStageAddBranch extends SfCommand { 'Production', ]); - expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + expect(ctx.stdout).to.contain('Successfully added environment to the stage.'); expect(ctx.stdout).to.contain('0Xp000000000001'); expect(ctx.stdout).to.contain('0Hi000000000001'); expect(ctx.stdout).to.contain('Production_Org'); @@ -152,7 +152,7 @@ describe('devops stage add-environment', () => { expect(ctx.stdout).to.contain('Open the following URL'); expect(ctx.stdout).to.contain('login.salesforce.com'); - expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + expect(ctx.stdout).to.contain('Successfully added environment to the stage.'); }); }); @@ -199,7 +199,7 @@ describe('devops stage add-environment', () => { 'Sandbox', ]); - expect(ctx.stdout).to.contain('Successfully created and authenticated the environment.'); + expect(ctx.stdout).to.contain('Successfully added environment to the stage.'); expect(ctx.stdout).to.contain('UAT_Sandbox'); expect(ctx.stdout).to.contain('00D000000000002'); });