diff --git a/command-snapshot.json b/command-snapshot.json index 05fae7f..154c263 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,4 +1,12 @@ [ + { + "alias": [], + "command": "devops:pipeline:activate", + "flagAliases": [], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "pipeline-id", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:pipeline:attach-project", @@ -7,6 +15,34 @@ "flags": ["api-version", "flags-dir", "json", "pipeline-id", "project-id", "target-org"], "plugin": "@salesforce/plugin-devops-center" }, + { + "alias": [], + "command": "devops:pipeline:create", + "flagAliases": [], + "flagChars": ["d", "n", "o", "r"], + "flags": [ + "api-version", + "bitbucket-project", + "create-repo", + "description", + "flags-dir", + "json", + "name", + "repo", + "repo-owner", + "repo-type", + "target-org" + ], + "plugin": "@salesforce/plugin-devops-center" + }, + { + "alias": [], + "command": "devops:pipeline:stage:add", + "flagAliases": [], + "flagChars": ["n", "o"], + "flags": ["api-version", "flags-dir", "json", "name", "next-stage-id", "pipeline-id", "target-org"], + "plugin": "@salesforce/plugin-devops-center" + }, { "alias": [], "command": "devops:project:create", diff --git a/messages/devops.pipeline.activate.md b/messages/devops.pipeline.activate.md new file mode 100644 index 0000000..d49de68 --- /dev/null +++ b/messages/devops.pipeline.activate.md @@ -0,0 +1,29 @@ +# summary + +Activate a DevOps Center pipeline for deployments. + +# description + +A pipeline must have at least one stage before you activate it. You can't modify the pipeline stages after you activate and promote changes through it. + +# flags.target-org.summary + +Username or alias of the DevOps Center org. + +# flags.pipeline-id.summary + +ID of the pipeline. + +# examples + +- Activate a pipeline using the pipeline ID. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 + +# error.NoStages + +Can't activate pipeline %s. Add at least one stage, then try again. + +# error.AlreadyActive + +Pipeline %s is already active. diff --git a/messages/devops.pipeline.create.md b/messages/devops.pipeline.create.md new file mode 100644 index 0000000..a4506e0 --- /dev/null +++ b/messages/devops.pipeline.create.md @@ -0,0 +1,81 @@ +# summary + +Create a DevOps Center pipeline. + +# description + +Provide the URL of an existing repository, or use `--create-repo` with a repository name to create one. After you create the pipeline, add stages, and activate the pipeline. + +# flags.target-org.summary + +Username or alias of the DevOps Center org. + +# flags.name.summary + +Name of the pipeline. + +# flags.repo.summary + +URL of an existing repository or the name of a repository to create. + +# flags.repo-type.summary + +Type of the source code repository. Required when creating a repository using '--create-repo'. + +# flags.create-repo.summary + +Create a repository if it doesn't exist. + +# flags.repo-owner.summary + +Owner (organization or user) of the repository. Required when creating a repository using '--create-repo'. + +# flags.bitbucket-project.summary + +Bitbucket project key for the repository. Used when creating a Bitbucket repository with '--create-repo'. + +# flags.description.summary + +Description of the pipeline. + +# examples + +- Create a pipeline and associate it with an existing GitHub repository. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://github.com/myorg/myrepo + +- Create a pipeline and associate it with a new GitHub repository. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo my-new-repo --repo-type github --repo-owner myorg --create-repo + +- Create a pipeline with a description and associate it with a Bitbucket repository. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://bitbucket.org/myorg/myrepo --description "Main CI/CD pipeline for production releases" + +# error.RepoTypeRequired + +The --repo-type flag is required when using --create-repo. Specify --repo-type github or --repo-type bitbucket. + +# error.RepoCreationFailed + +Failed to create repository "%s". Verify the --repo-owner has permission to create repositories and the repository name is available. Details: %s + +# error.RepoNotFound + +Repository "%s" was not found or you don't have access. Verify the repository URL is correct and your DevOps Center org has the required VCS credentials. + +# error.RepoValidationFailed + +Failed to validate repository "%s". When using an existing repository, provide the full URL (e.g. https://github.com/owner/repo). To create a new repository, use --create-repo with --repo-type and --repo-owner. + +# error.RepoOwnerRequired + +The --repo-owner flag is required when using --create-repo. Specify the GitHub or Bitbucket organization or user that will own the repository. + +# error.VcsCredentialsMissing + +No VCS credentials found for repository "%s". Connect to Bitbucket in your DevOps Center org before creating a pipeline. + +# error.RepoTypeDetectionFailed + +Unable to detect the repository type from the URL "%s". Use --repo-type to specify the repository type. diff --git a/messages/devops.pipeline.stage.add.md b/messages/devops.pipeline.stage.add.md new file mode 100644 index 0000000..0fd8f92 --- /dev/null +++ b/messages/devops.pipeline.stage.add.md @@ -0,0 +1,37 @@ +# summary + +Add a stage to a DevOps Center pipeline. + +# description + +Inserts an empty stage before the stage specified by `--next-stage-id`. The new stage doesn't include a branch or environment. Configure them separately after you create the stage. + +# flags.target-org.summary + +Username or alias of the DevOps Center org. + +# flags.pipeline-id.summary + +ID of the pipeline where the stage is added. + +# flags.name.summary + +Name of the pipeline stage, such as Integration, UAT, or Staging. + +# flags.next-stage-id.summary + +ID of the stage that follows the new stage in the pipeline. + +# examples + +- Add a Development stage before Integration in a specific pipeline. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --name "Development" --next-stage-id 0Xc000000000002 + +- Add a QA stage before UAT in a specific pipeline. + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --pipeline-id 0XB000000000001 --name "QA" --next-stage-id 0Xc000000000003 + +# error.StageNotFound + +Stage %s not found in pipeline %s. Check the stage ID and try again. diff --git a/package.json b/package.json index a88bbca..38e8ed9 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,12 @@ "description": "Commands for managing DevOps Center pull requests." }, "pipeline": { - "description": "Commands for managing DevOps Center pipelines." + "description": "Commands for managing DevOps Center pipelines.", + "subtopics": { + "stage": { + "description": "Commands for managing pipeline stages." + } + } } } } diff --git a/schemas/devops-pipeline-activate.json b/schemas/devops-pipeline-activate.json new file mode 100644 index 0000000..9cf6884 --- /dev/null +++ b/schemas/devops-pipeline-activate.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/ActivatePipelineResult", + "definitions": { + "ActivatePipelineResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "pipelineId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "stageCount": { + "type": "number" + }, + "error": { + "type": "string" + } + }, + "required": ["success", "pipelineId"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-pipeline-create.json b/schemas/devops-pipeline-create.json new file mode 100644 index 0000000..ac72ed6 --- /dev/null +++ b/schemas/devops-pipeline-create.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CreatePipelineResult", + "definitions": { + "CreatePipelineResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "pipelineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "repository": { + "$ref": "#/definitions/RepoInfo" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + }, + "RepoInfo": { + "type": "object", + "properties": { + "repoUrl": { + "type": "string" + }, + "repoType": { + "type": "string" + }, + "created": { + "type": "boolean" + } + }, + "required": ["repoUrl", "repoType", "created"], + "additionalProperties": false + } + } +} diff --git a/schemas/devops-pipeline-stage-add.json b/schemas/devops-pipeline-stage-add.json new file mode 100644 index 0000000..4d718b1 --- /dev/null +++ b/schemas/devops-pipeline-stage-add.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AddPipelineStageResult", + "definitions": { + "AddPipelineStageResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "stageId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nextStageId": { + "type": "string" + }, + "pipelineId": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["success"], + "additionalProperties": false + } + } +} diff --git a/src/commands/devops/pipeline/activate.ts b/src/commands/devops/pipeline/activate.ts new file mode 100644 index 0000000..8e6087f --- /dev/null +++ b/src/commands/devops/pipeline/activate.ts @@ -0,0 +1,89 @@ +/* + * 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 { activatePipeline, ActivatePipelineResult } from '../../../utils/activatePipeline.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.pipeline.activate'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineActivate 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, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineActivate); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-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; + } + + if (stages.length === 0) { + this.error(messages.getMessage('error.NoStages', [pipelineId])); + } + + let result: ActivatePipelineResult; + try { + result = await activatePipeline({ connection, pipelineId }); + result.stageCount = stages.length; + } 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('already active') || errMsg.includes('ALREADY_ACTIVE')) { + this.error(messages.getMessage('error.AlreadyActive', [pipelineId])); + } + throw error; + } + + if (result.success) { + this.log('Successfully activated the pipeline.'); + this.log(` Pipeline ID: ${pipelineId}`); + this.log(` Status: ${result.status ?? 'Active'}`); + this.log(` Stages: ${result.stageCount ?? stages.length}`); + } else { + this.error(`Failed to activate pipeline: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/commands/devops/pipeline/attach-project.ts b/src/commands/devops/pipeline/attach-project.ts index 76ee940..39e2f57 100644 --- a/src/commands/devops/pipeline/attach-project.ts +++ b/src/commands/devops/pipeline/attach-project.ts @@ -28,11 +28,7 @@ export default class DevopsPipelineAttachProject 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(), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + char: 'n', + required: true, + }), + repo: Flags.string({ + summary: messages.getMessage('flags.repo.summary'), + char: 'r', + required: true, + }), + 'repo-type': Flags.string({ + summary: messages.getMessage('flags.repo-type.summary'), + options: ['github', 'bitbucket'], + }), + 'create-repo': Flags.boolean({ + summary: messages.getMessage('flags.create-repo.summary'), + default: false, + }), + 'repo-owner': Flags.string({ + summary: messages.getMessage('flags.repo-owner.summary'), + }), + 'bitbucket-project': Flags.string({ + summary: messages.getMessage('flags.bitbucket-project.summary'), + }), + description: Flags.string({ + summary: messages.getMessage('flags.description.summary'), + char: 'd', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineCreate); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const repoType = this.resolveRepoType(flags['repo-type'], flags['repo'], flags['create-repo']); + + if (flags['create-repo'] && !flags['repo-owner']) { + this.error(messages.getMessage('error.RepoOwnerRequired')); + } + + let result: CreatePipelineResult; + try { + result = await createPipeline({ + connection, + name: flags['name'], + description: flags['description'], + repo: flags['repo'], + repoType, + createRepo: flags['create-repo'], + repoOwner: flags['repo-owner'], + bitbucketProject: flags['bitbucket-project'], + }); + } 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')); + } + this.handleApiError(errMsg, flags['repo']); + throw error; + } + + if (result.success) { + this.printSuccessOutput(result, flags['repo'], org.getUsername()); + } else { + this.error(`Failed to create pipeline: ${result.error ?? ''}`); + } + + return result; + } + + private resolveRepoType(flagRepoType: string | undefined, repo: string, isCreateRepo: boolean): string { + if (isCreateRepo && !flagRepoType) { + this.error(messages.getMessage('error.RepoTypeRequired')); + } + if (flagRepoType) return flagRepoType; + + const detected = detectRepoType(repo); + if (!detected) { + this.error(messages.getMessage('error.RepoTypeDetectionFailed', [repo])); + } + return detected; + } + + private handleApiError(errMsg: string, repo: string): void { + if (errMsg.includes('REPOSITORY_CREATION_FAILED')) { + this.error(messages.getMessage('error.RepoCreationFailed', [repo, errMsg])); + } + if (errMsg.includes('REPO_NOT_FOUND_OR_UNAUTHORIZED')) { + this.error(messages.getMessage('error.RepoNotFound', [repo])); + } + if (errMsg.includes('SOURCE_CODE_SERVICE_ERROR')) { + this.error(messages.getMessage('error.RepoValidationFailed', [repo])); + } + if (errMsg.includes('BITBUCKET_API_ERROR') && errMsg.includes('ProviderInfo is missing')) { + this.error(messages.getMessage('error.VcsCredentialsMissing', [repo])); + } + } + + private printSuccessOutput(result: CreatePipelineResult, repoFlag: string, username: string | undefined): void { + if (result.repository?.created) { + this.log(`Created repository: ${repoFlag} (${result.repository.repoType})`); + } + this.log(`Successfully created pipeline: ${result.name ?? ''}`); + this.log(` Pipeline ID: ${result.pipelineId ?? ''}`); + this.log(` Repository: ${result.repository?.repoUrl ?? ''} (${result.repository?.repoType ?? ''})`); + this.log(` Status: ${result.status ?? 'Inactive'}`); + this.log(' Next steps:'); + const orgLabel = username ?? ''; + const pipelineIdLabel = result.pipelineId ?? ''; + this.log( + ` Add pipeline stages: sf devops pipeline stage add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel}` + ); + this.log( + ` Attach a project: sf devops pipeline attach-project --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id ` + ); + } +} diff --git a/src/commands/devops/pipeline/stage/add.ts b/src/commands/devops/pipeline/stage/add.ts new file mode 100644 index 0000000..a898c68 --- /dev/null +++ b/src/commands/devops/pipeline/stage/add.ts @@ -0,0 +1,101 @@ +/* + * 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 { addPipelineStage, AddPipelineStageResult } from '../../../../utils/addPipelineStage.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.pipeline.stage.add'); +const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); + +export default class DevopsPipelineStageAdd 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, + }), + name: Flags.string({ + summary: messages.getMessage('flags.name.summary'), + char: 'n', + required: true, + }), + 'next-stage-id': Flags.salesforceId({ + summary: messages.getMessage('flags.next-stage-id.summary'), + required: true, + char: undefined, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DevopsPipelineStageAdd); + const org: Org = flags['target-org']; + const connection = org.getConnection(flags['api-version']); + const pipelineId = flags['pipeline-id']; + const nextStageId = flags['next-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; + } + + if (!stages.some((s) => s.Id === nextStageId)) { + this.error(messages.getMessage('error.StageNotFound', [nextStageId, pipelineId])); + } + + let result: AddPipelineStageResult; + try { + result = await addPipelineStage({ + connection, + pipelineId, + name: flags['name'], + nextStageId, + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) { + this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled')); + } + throw error; + } + + if (result.success) { + this.log(`Successfully added stage "${result.name ?? ''}" to the pipeline.`); + this.log(` Stage ID: ${result.stageId ?? ''}`); + this.log(` Position: before "${nextStageId}"`); + this.log(` Pipeline ID: ${pipelineId}`); + } else { + this.error(`Failed to add stage: ${result.error ?? ''}`); + } + + return result; + } +} diff --git a/src/commands/devops/project/create.ts b/src/commands/devops/project/create.ts index a02e9b5..122d8b8 100644 --- a/src/commands/devops/project/create.ts +++ b/src/commands/devops/project/create.ts @@ -28,11 +28,7 @@ export default class DevopsProjectCreate extends SfCommand public static readonly examples = messages.getMessages('examples'); public static readonly flags = { - 'target-org': Flags.requiredOrg({ - char: 'o', - summary: messages.getMessage('flags.target-org.summary'), - required: true, - }), + 'target-org': Flags.requiredOrg(), 'api-version': Flags.orgApiVersion(), name: Flags.string({ summary: messages.getMessage('flags.name.summary'), diff --git a/src/commands/devops/pull-request/create.ts b/src/commands/devops/pull-request/create.ts index ac840ff..e74a496 100644 --- a/src/commands/devops/pull-request/create.ts +++ b/src/commands/devops/pull-request/create.ts @@ -34,11 +34,7 @@ export default class DevopsPullRequestCreate extends SfCommand { + const { connection, pipelineId } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines/${pipelineId}/activate`; + + const data = await connection.request({ + method: 'POST', + url: path, + body: '{}', + headers: { 'Content-Type': 'application/json' }, + }); + + return { + success: true, + pipelineId, + status: data.status ?? 'Active', + }; +} diff --git a/src/utils/addPipelineStage.ts b/src/utils/addPipelineStage.ts new file mode 100644 index 0000000..2f3a288 --- /dev/null +++ b/src/utils/addPipelineStage.ts @@ -0,0 +1,71 @@ +/* + * 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 AddPipelineStageParams = { + connection: Connection; + pipelineId: string; + name: string; + nextStageId: string; +}; + +export type AddPipelineStageResult = { + success: boolean; + stageId?: string; + name?: string; + nextStageId?: string; + pipelineId?: string; + error?: string; +}; + +type SObjectCreateResult = { + id: string; + success: boolean; + errors?: Array<{ message: string }>; +}; + +/** + * Adds a stage to a DevOps Center pipeline via sObject create. + * POST /services/data/v{version}/sobjects/DevopsPipelineStage + */ +export async function addPipelineStage(params: AddPipelineStageParams): Promise { + const { connection, pipelineId, name, nextStageId } = params; + + const result = await connection.sobject('DevopsPipelineStage').create({ + Name: name, + DevopsPipelineId: pipelineId, + NextStageId: nextStageId, + }); + + const createResult = result as unknown as SObjectCreateResult; + + if (!createResult.success) { + const errorMsg = createResult.errors?.map((e) => e.message).join('; ') ?? 'Unknown error'; + return { + success: false, + error: errorMsg, + }; + } + + return { + success: true, + stageId: createResult.id, + name, + nextStageId, + pipelineId, + }; +} diff --git a/src/utils/createPipeline.ts b/src/utils/createPipeline.ts new file mode 100644 index 0000000..4191ecb --- /dev/null +++ b/src/utils/createPipeline.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 { Connection } from '@salesforce/core'; + +export type RepoInfo = { + repoUrl: string; + repoType: string; + created: boolean; +}; + +export type CreatePipelineParams = { + connection: Connection; + name: string; + description?: string; + repo: string; + repoType: string; + createRepo?: boolean; + repoOwner?: string; + bitbucketProject?: string; +}; + +export type CreatePipelineResult = { + success: boolean; + pipelineId?: string; + name?: string; + description?: string; + status?: string; + repository?: RepoInfo; + error?: string; +}; + +type ConnectPipelineResponse = { + id: string; + message: string; + status: string; +}; + +const DEFAULT_STAGES = [{ name: 'Integration' }, { name: 'UAT' }, { name: 'Staging' }, { name: 'Production' }]; + +/** + * Detects repo type from a URL. Returns 'github' or 'bitbucket', or undefined. + */ +export function detectRepoType(repoUrl: string): string | undefined { + const lower = repoUrl.toLowerCase(); + if (lower.includes('github.com')) return 'github'; + if (lower.includes('bitbucket.org')) return 'bitbucket'; + return undefined; +} + +/** + * Creates a new DevOps Center pipeline via the Connect API. + * POST /services/data/v{version}/connect/devops/pipelines + */ +export async function createPipeline(params: CreatePipelineParams): Promise { + const { connection, name, description, repo, repoType, createRepo, repoOwner, bitbucketProject } = params; + + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines`; + + const payload: Record = { + name, + vcsType: repoType, + stages: DEFAULT_STAGES, + }; + + if (createRepo) { + payload.createVcsRepo = true; + payload.vcsRepoName = repo; + payload.vcsRepoOwner = repoOwner; + + if (repoType === 'bitbucket' && repoOwner) { + const providerInfo: Record = { bitbucketWorkspace: repoOwner }; + if (bitbucketProject) { + providerInfo.bitbucketProject = bitbucketProject; + } + payload.repoProviderInfo = JSON.stringify(providerInfo); + } + } else { + payload.vcsRepoUrl = repo; + } + + if (description) { + payload.description = description; + } + + const data = await connection.request({ + method: 'POST', + url: path, + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + + return { + success: true, + pipelineId: data.id, + name, + description, + status: data.status ?? 'Inactive', + repository: { + repoUrl: repo, + repoType, + created: createRepo ?? false, + }, + }; +} diff --git a/test/commands/devops/pipeline/activate.test.ts b/test/commands/devops/pipeline/activate.test.ts new file mode 100644 index 0000000..67c6fd8 --- /dev/null +++ b/test/commands/devops/pipeline/activate.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import esmock from 'esmock'; +import { expect, test } from '@oclif/test'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; + +describe('devops pipeline activate', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ActivateCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const activatePipelineStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/pipeline/activate.js', { + '../../../../src/utils/activatePipeline.js': { + activatePipeline: activatePipelineStub, + }, + '../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + ActivateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + activatePipelineStub.reset(); + fetchPipelineStagesStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful activation', () => { + test + .stdout() + .stderr() + .it('activates pipeline and logs success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([ + { Id: '1', Name: 'Integration' }, + { Id: '2', Name: 'UAT' }, + { Id: '3', Name: 'Production' }, + ]); + activatePipelineStub.resolves({ + success: true, + pipelineId: '0XB000000000001', + status: 'Active', + }); + + await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + + expect(ctx.stdout).to.contain('Successfully activated the pipeline.'); + expect(ctx.stdout).to.contain('0XB000000000001'); + expect(ctx.stdout).to.contain('Active'); + expect(ctx.stdout).to.contain('3'); + }); + }); + + describe('no stages error', () => { + test + .stdout() + .stderr() + .it('errors when pipeline has no stages', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([]); + + try { + await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Add at least one stage'); + }); + }); + + describe('already active error', () => { + test + .stdout() + .stderr() + .it('errors when pipeline is already active', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + activatePipelineStub.rejects(new Error('Pipeline is already active')); + + try { + await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already active'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '1', Name: 'Integration' }]); + activatePipelineStub.rejects(new Error('Network error')); + + try { + await ActivateCommand.run(['--target-org', 'testOrg', '--pipeline-id', '0XB000000000001']); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/pipeline/create.test.ts b/test/commands/devops/pipeline/create.test.ts new file mode 100644 index 0000000..663622f --- /dev/null +++ b/test/commands/devops/pipeline/create.test.ts @@ -0,0 +1,262 @@ +/* + * 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 create', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let CreateCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const createPipelineStub = sinon.stub(); + const detectRepoTypeStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../src/commands/devops/pipeline/create.js', { + '../../../../src/utils/createPipeline.js': { + createPipeline: createPipelineStub, + detectRepoType: detectRepoTypeStub, + }, + }); + CreateCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + createPipelineStub.reset(); + detectRepoTypeStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('create with existing repo URL', () => { + test + .stdout() + .stderr() + .it('creates pipeline and logs success with next steps', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detectRepoTypeStub.returns('github'); + createPipelineStub.resolves({ + success: true, + pipelineId: '0XB000000000001', + name: 'Release Pipeline', + status: 'Inactive', + repository: { + repoUrl: 'https://github.com/myorg/myrepo', + repoType: 'github', + created: false, + }, + }); + + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Release Pipeline', + '--repo', + 'https://github.com/myorg/myrepo', + ]); + + expect(ctx.stdout).to.contain('Successfully created pipeline: Release Pipeline'); + expect(ctx.stdout).to.contain('0XB000000000001'); + expect(ctx.stdout).to.contain('https://github.com/myorg/myrepo'); + expect(ctx.stdout).to.contain('Next steps'); + expect(ctx.stdout).to.contain('sf devops pipeline stage add'); + expect(ctx.stdout).to.contain('sf devops pipeline attach-project'); + }); + }); + + describe('create with --create-repo', () => { + test + .stdout() + .stderr() + .it('logs repo creation and pipeline success', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + createPipelineStub.resolves({ + success: true, + pipelineId: '0XB000000000002', + name: 'Release Pipeline', + status: 'Inactive', + repository: { + repoUrl: 'https://github.com/myorg/my-new-repo', + repoType: 'github', + created: true, + }, + }); + + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Release Pipeline', + '--repo', + 'my-new-repo', + '--repo-type', + 'github', + '--repo-owner', + 'myorg', + '--create-repo', + ]); + + expect(ctx.stdout).to.contain('Created repository: my-new-repo (github)'); + expect(ctx.stdout).to.contain('Successfully created pipeline: Release Pipeline'); + }); + }); + + describe('--create-repo without --repo-type', () => { + test + .stdout() + .stderr() + .it('errors when --repo-type is missing', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + + try { + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Pipeline', + '--repo', + 'my-repo', + '--create-repo', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('--repo-type flag is required'); + }); + }); + + describe('--create-repo without --repo-owner', () => { + test + .stdout() + .stderr() + .it('errors when --repo-owner is missing', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + + try { + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Pipeline', + '--repo', + 'my-repo', + '--repo-type', + 'github', + '--create-repo', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('--repo-owner flag is required'); + }); + }); + + describe('undetectable repo type', () => { + test + .stdout() + .stderr() + .it('errors when repo type cannot be detected from URL', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detectRepoTypeStub.returns(undefined); + + try { + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Pipeline', + '--repo', + 'https://gitlab.com/myorg/myrepo', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('Unable to detect the repository type'); + }); + }); + + 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); + detectRepoTypeStub.returns('github'); + createPipelineStub.rejects(new Error("sObject type 'Pipeline' is not supported")); + + try { + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Pipeline', + '--repo', + 'https://github.com/myorg/myrepo', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + detectRepoTypeStub.returns('github'); + createPipelineStub.rejects(new Error('Network error')); + + try { + await CreateCommand.run([ + '--target-org', + 'testOrg', + '--name', + 'Pipeline', + '--repo', + 'https://github.com/myorg/myrepo', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/commands/devops/pipeline/stage/add.test.ts b/test/commands/devops/pipeline/stage/add.test.ts new file mode 100644 index 0000000..5d31ba6 --- /dev/null +++ b/test/commands/devops/pipeline/stage/add.test.ts @@ -0,0 +1,170 @@ +/* + * 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 add', () => { + let sandbox: sinon.SinonSandbox; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let AddCommand: any; + const mockConnection = { getApiVersion: () => '65.0' }; + const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; + const addPipelineStageStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + + before(async () => { + const mod = await esmock('../../../../../src/commands/devops/pipeline/stage/add.js', { + '../../../../../src/utils/addPipelineStage.js': { + addPipelineStage: addPipelineStageStub, + }, + '../../../../../src/utils/pipelineUtils.js': { + fetchPipelineStages: fetchPipelineStagesStub, + }, + }); + AddCommand = mod.default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + addPipelineStageStub.reset(); + fetchPipelineStagesStub.reset(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('successful stage addition', () => { + test + .stdout() + .stderr() + .it('adds stage 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: '0Xc000000000002', Name: 'Integration' }]); + addPipelineStageStub.resolves({ + success: true, + stageId: '0Xc000000000005', + name: 'Development', + nextStageId: '0Xc000000000002', + pipelineId: '0XB000000000001', + }); + + await AddCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--name', + 'Development', + '--next-stage-id', + '0Xc000000000002', + ]); + + expect(ctx.stdout).to.contain('Successfully added stage "Development" to the pipeline.'); + expect(ctx.stdout).to.contain('0Xc000000000005'); + expect(ctx.stdout).to.contain('0XB000000000001'); + }); + }); + + describe('stage not found error', () => { + test + .stdout() + .stderr() + .it('shows friendly error when next stage not found', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xc000000000002', Name: 'Integration' }]); + + try { + await AddCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--name', + 'Development', + '--next-stage-id', + '0Xc000000000099', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('not found in pipeline'); + }); + }); + + describe('DevOps Center not enabled', () => { + test + .stdout() + .stderr() + .it('shows DevOps Center not enabled error', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.rejects(new Error("sObject type 'DevopsPipelineStage' is not supported")); + + try { + await AddCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--name', + 'Development', + '--next-stage-id', + '0Xc000000000002', + ]); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain("DevOps Center isn't enabled"); + }); + }); + + describe('rethrows other errors', () => { + test + .stdout() + .stderr() + .it('rethrows non-DevOps errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xc000000000002', Name: 'Integration' }]); + addPipelineStageStub.rejects(new Error('Network error')); + + try { + await AddCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0XB000000000001', + '--name', + 'Development', + '--next-stage-id', + '0Xc000000000002', + ]); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Network error'); + } + }); + }); +}); diff --git a/test/utils/activatePipeline.test.ts b/test/utils/activatePipeline.test.ts new file mode 100644 index 0000000..2686000 --- /dev/null +++ b/test/utils/activatePipeline.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { activatePipeline } from '../../src/utils/activatePipeline.js'; + +describe('activatePipeline utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls activate endpoint and returns success', async () => { + (connectionStub.request as sinon.SinonStub).resolves({}); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await activatePipeline({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + }); + + expect(result.success).to.be.true; + expect(result.pipelineId).to.equal('0XB000000000001'); + expect(result.status).to.equal('Active'); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.url).to.contain('/connect/devops/pipelines/0XB000000000001/activate'); + expect(callArgs.method).to.equal('POST'); + }); + + it('propagates API errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + try { + await activatePipeline({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Bad Request'); + } + }); +}); diff --git a/test/utils/addPipelineStage.test.ts b/test/utils/addPipelineStage.test.ts new file mode 100644 index 0000000..faa93f3 --- /dev/null +++ b/test/utils/addPipelineStage.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { addPipelineStage } from '../../src/utils/addPipelineStage.js'; + +describe('addPipelineStage utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + let createStub: sinon.SinonStub; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + createStub = sinon.stub(); + (connectionStub.sobject as sinon.SinonStub).returns({ create: createStub }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('creates sObject record and returns result', async () => { + createStub.resolves({ id: '0Xc000000000005', success: true }); + + const result = await addPipelineStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + name: 'Development', + nextStageId: '0Xc000000000002', + }); + + expect(result.success).to.be.true; + expect(result.stageId).to.equal('0Xc000000000005'); + expect(result.name).to.equal('Development'); + expect(result.nextStageId).to.equal('0Xc000000000002'); + expect(result.pipelineId).to.equal('0XB000000000001'); + + expect((connectionStub.sobject as sinon.SinonStub).calledWith('DevopsPipelineStage')).to.be.true; + const createArg = createStub.firstCall.args[0] as Record; + expect(createArg.Name).to.equal('Development'); + expect(createArg.DevopsPipelineId).to.equal('0XB000000000001'); + expect(createArg.NextStageId).to.equal('0Xc000000000002'); + }); + + it('returns error when sObject create fails', async () => { + createStub.resolves({ success: false, errors: [{ message: 'Invalid pipeline ID' }] }); + + const result = await addPipelineStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + name: 'Fail', + nextStageId: '0Xc000000000002', + }); + + expect(result.success).to.be.false; + expect(result.error).to.contain('Invalid pipeline ID'); + }); + + it('propagates connection errors', async () => { + createStub.rejects(new Error('Connection refused')); + + try { + await addPipelineStage({ + connection: connectionStub as unknown as Connection, + pipelineId: '0XB000000000001', + name: 'Fail', + nextStageId: '0Xc000000000002', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Connection refused'); + } + }); +}); diff --git a/test/utils/createPipeline.test.ts b/test/utils/createPipeline.test.ts new file mode 100644 index 0000000..7ffc509 --- /dev/null +++ b/test/utils/createPipeline.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { createPipeline, detectRepoType } from '../../src/utils/createPipeline.js'; + +describe('createPipeline utilities', () => { + let connectionStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('detectRepoType', () => { + it('detects github from URL', () => { + expect(detectRepoType('https://github.com/myorg/myrepo')).to.equal('github'); + }); + + it('detects bitbucket from URL', () => { + expect(detectRepoType('https://bitbucket.org/myorg/myrepo')).to.equal('bitbucket'); + }); + + it('returns undefined for unknown URLs', () => { + expect(detectRepoType('https://gitlab.com/myorg/myrepo')).to.be.undefined; + }); + + it('returns undefined for plain repo names', () => { + expect(detectRepoType('my-new-repo')).to.be.undefined; + }); + }); + + describe('createPipeline', () => { + it('sends correct payload for existing repo and returns result', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0XB000000000001', + message: 'Pipeline created', + status: 'Inactive', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Release Pipeline', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + }); + + expect(result.success).to.be.true; + expect(result.pipelineId).to.equal('0XB000000000001'); + expect(result.name).to.equal('Release Pipeline'); + expect(result.status).to.equal('Inactive'); + expect(result.repository?.repoUrl).to.equal('https://github.com/myorg/myrepo'); + expect(result.repository?.repoType).to.equal('github'); + expect(result.repository?.created).to.be.false; + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.name).to.equal('Release Pipeline'); + expect(body.vcsType).to.equal('github'); + expect(body.vcsRepoUrl).to.equal('https://github.com/myorg/myrepo'); + expect(body.stages).to.deep.equal([ + { name: 'Integration' }, + { name: 'UAT' }, + { name: 'Staging' }, + { name: 'Production' }, + ]); + expect(body).to.not.have.property('createVcsRepo'); + expect(body).to.not.have.property('vcsRepoName'); + }); + + it('sends correct payload for creating a new repo', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0XB000000000002', + message: 'Pipeline and repo created', + status: 'Inactive', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'New Pipeline', + repo: 'my-new-repo', + repoType: 'github', + createRepo: true, + repoOwner: 'myorg', + }); + + expect(result.success).to.be.true; + expect(result.repository?.created).to.be.true; + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.createVcsRepo).to.be.true; + expect(body.vcsRepoName).to.equal('my-new-repo'); + expect(body.vcsRepoOwner).to.equal('myorg'); + expect(body).to.not.have.property('vcsRepoUrl'); + }); + + it('includes description when provided', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0XB000000000003', + message: 'Created', + status: 'Inactive', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + const result = await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Described Pipeline', + description: 'My description', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + }); + + expect(result.success).to.be.true; + expect(result.description).to.equal('My description'); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.description).to.equal('My description'); + }); + + it('propagates API errors', async () => { + (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + try { + await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Fail', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Bad Request'); + } + }); + }); +});