From 2eb0031a410d8da4b679219107f0ad37f6463a6a Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Mon, 10 Aug 2026 12:37:32 +0530 Subject: [PATCH 1/2] feat: support attaching projects in pipeline create Add a repeatable --project-id flag to devops pipeline create, which maps to the Connect API's projectIds field so one or more projects can be associated with the pipeline at creation time. Co-Authored-By: Claude Opus 4.8 (1M context) --- command-snapshot.json | 1 + messages/devops.pipeline.create.md | 8 +++++ src/commands/devops/pipeline/create.ts | 26 ++++++++++++---- src/utils/createPipeline.ts | 19 ++++++++++-- test/utils/createPipeline.test.ts | 41 ++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/command-snapshot.json b/command-snapshot.json index 40f575f..89c83d5 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -12,6 +12,7 @@ "flags-dir", "json", "name", + "project-id", "repo", "repo-owner", "repo-type", diff --git a/messages/devops.pipeline.create.md b/messages/devops.pipeline.create.md index 835bf56..67ae06f 100644 --- a/messages/devops.pipeline.create.md +++ b/messages/devops.pipeline.create.md @@ -42,6 +42,10 @@ Bitbucket project key to associate with the repository. Optional when creating a Name of a pipeline stage, in promotion order. Repeat the flag for each stage. Defaults to Integration, UAT, Staging, and Production. +# flags.project-id.summary + +ID of a project to associate with the pipeline. Repeat the flag to associate multiple projects. + # examples - Create a pipeline and associate it with an existing GitHub repository: @@ -64,6 +68,10 @@ Name of a pipeline stage, in promotion order. Repeat the flag for each stage. De <%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://github.com/myorg/myrepo --stage Dev --stage QA --stage Prod +- Create a pipeline and associate one or more projects with it: + + <%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://github.com/myorg/myrepo --project-id 0Hn000000000001 --project-id 0Hn000000000002 + # error.RepoTypeRequired The --repo-type flag is required when using --create-repo. Specify --repo-type github or --repo-type bitbucket. diff --git a/src/commands/devops/pipeline/create.ts b/src/commands/devops/pipeline/create.ts index efcebd9..12aa4e5 100644 --- a/src/commands/devops/pipeline/create.ts +++ b/src/commands/devops/pipeline/create.ts @@ -69,6 +69,11 @@ export default class DevopsPipelineCreate extends SfCommand { @@ -110,6 +115,7 @@ export default class DevopsPipelineCreate extends SfCommand 0) { + this.log(` Projects: ${projectIds.join(', ')}`); + } 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 project add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id ` - ); + if (!projectIds || projectIds.length === 0) { + this.log( + ` Attach a project: sf devops pipeline project add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id ` + ); + } } } diff --git a/src/utils/createPipeline.ts b/src/utils/createPipeline.ts index 8b2ce5a..7c79799 100644 --- a/src/utils/createPipeline.ts +++ b/src/utils/createPipeline.ts @@ -33,6 +33,7 @@ export type CreatePipelineParams = { bitbucketWorkspace?: string; bitbucketProjectKey?: string; stages?: string[]; + projectIds?: string[]; }; export type CreatePipelineResult = { @@ -119,8 +120,18 @@ export class GitHubOwnerNotFoundError extends Error { * POST /services/data/v{version}/connect/devops/pipelines */ export async function createPipeline(params: CreatePipelineParams): Promise { - const { connection, name, repo, repoType, createRepo, repoOwner, bitbucketWorkspace, bitbucketProjectKey, stages } = - params; + const { + connection, + name, + repo, + repoType, + createRepo, + repoOwner, + bitbucketWorkspace, + bitbucketProjectKey, + stages, + projectIds, + } = params; const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines`; @@ -132,6 +143,10 @@ export async function createPipeline(params: CreatePipelineParams): Promise ({ name: stageName })), }; + if (projectIds && projectIds.length > 0) { + payload.projectIds = projectIds; + } + if (createRepo) { payload.createVcsRepo = true; payload.vcsRepoName = repo; diff --git a/test/utils/createPipeline.test.ts b/test/utils/createPipeline.test.ts index 9200755..1ad646c 100644 --- a/test/utils/createPipeline.test.ts +++ b/test/utils/createPipeline.test.ts @@ -278,6 +278,47 @@ describe('createPipeline utilities', () => { ]); }); + it('includes projectIds when projects are provided', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0XB000000000007', + message: 'Created', + status: 'Inactive', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Pipeline With Projects', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + projectIds: ['0Hn000000000001', '0Hn000000000002'], + }); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.projectIds).to.deep.equal(['0Hn000000000001', '0Hn000000000002']); + }); + + it('omits projectIds when none are provided', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '0XB000000000008', + message: 'Created', + status: 'Inactive', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + + await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Pipeline No Projects', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + }); + + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + const body = JSON.parse(callArgs.body as string) as Record; + expect(body).to.not.have.property('projectIds'); + }); + it('propagates API errors', async () => { (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); From c0c2354d96abe2872fa50a71b02b1152ec04d518 Mon Sep 17 00:00:00 2001 From: ad-shreya Date: Tue, 11 Aug 2026 14:17:07 +0530 Subject: [PATCH 2/2] fix: skip combine-details check when promoting to first stage Work items entering the pipeline's first stage come straight from dev branches and have no source stage, so Core's combine-details computation NPEs on a null PipelineStageObject. Skip the checkCombineDetails request in that case; still request it for all promotions to non-first stages, regardless of work-item count. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/devops/promotion/validate.ts | 28 ++++++-- .../devops/promotion/validate.test.ts | 64 ++++++++++++++++++- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/commands/devops/promotion/validate.ts b/src/commands/devops/promotion/validate.ts index 44e48ea..408e597 100644 --- a/src/commands/devops/promotion/validate.ts +++ b/src/commands/devops/promotion/validate.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Messages } from '@salesforce/core'; +import { Messages, Connection } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { validatePromotion, @@ -23,14 +23,32 @@ import { formatValidationDetails, hasSharedComponents, } from '../../../utils/promotionUtils.js'; -import { validateSalesforceId } from '../../../utils/soqlUtils.js'; +import { validateSalesforceId, normalizeSalesforceId } from '../../../utils/soqlUtils.js'; import { resolveProjectIdFromWorkItem } from '../../../utils/prepareWorkItem.js'; -import { getPipelineIdForProject } from '../../../utils/pipelineUtils.js'; +import { getPipelineIdForProject, fetchPipelineStages, computeFirstStageId } from '../../../utils/pipelineUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promotion.validate'); const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors'); +/** + * Combine details describe how work items that share components could be merged before promotion. + * We request them regardless of work-item count, except when promoting to the pipeline's first + * stage: those work items come straight from dev branches and have no source stage, so Core's + * combine-details path NPEs on a null source stage. + */ +async function shouldCheckCombineDetails( + connection: Connection, + pipelineId: string, + targetStageId: string +): Promise { + const stages = await fetchPipelineStages(connection, pipelineId); + const firstStageId = computeFirstStageId(stages); + const promotingToFirstStage = + Boolean(firstStageId) && normalizeSalesforceId(targetStageId) === normalizeSalesforceId(firstStageId!); + return !promotingToFirstStage; +} + export type PromotionValidateResult = { success: boolean; errorType: string | null; @@ -89,9 +107,11 @@ export default class DevopsPromotionValidate extends SfCommand { const validatePromotionStub = sinon.stub(); const resolveProjectIdFromWorkItemStub = sinon.stub(); const getPipelineIdForProjectStub = sinon.stub(); + const fetchPipelineStagesStub = sinon.stub(); + const computeFirstStageIdStub = sinon.stub(); const mockConnection = { getApiVersion: () => '65.0' }; const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection }; @@ -39,6 +41,8 @@ describe('devops promotion validate', () => { }, '../../../../src/utils/pipelineUtils.js': { getPipelineIdForProject: getPipelineIdForProjectStub, + fetchPipelineStages: fetchPipelineStagesStub, + computeFirstStageId: computeFirstStageIdStub, }, }); ValidateCommand = mod.default; @@ -49,6 +53,11 @@ describe('devops promotion validate', () => { validatePromotionStub.reset(); resolveProjectIdFromWorkItemStub.reset(); getPipelineIdForProjectStub.reset(); + fetchPipelineStagesStub.reset(); + computeFirstStageIdStub.reset(); + // Default: target stage is not the pipeline's first stage, so combine details are requested. + fetchPipelineStagesStub.resolves([]); + computeFirstStageIdStub.returns(undefined); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(Org, 'create' as any).returns(mockOrg); }); @@ -85,17 +94,68 @@ describe('devops promotion validate', () => { test .stdout() .stderr() - .it('requests combine details from the API', async () => { + .it('requests combine details from the API for multiple work items', async () => { resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); getPipelineIdForProjectStub.resolves('PIPE001'); validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); - await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + await ValidateCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); // checkCombineDetails (5th arg) must be true so the API returns shared-component info. expect(validatePromotionStub.firstCall.args[4]).to.be.true; }); + test + .stdout() + .stderr() + .it('requests combine details for a single work item promoted to a non-first stage', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); + + await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + // Combine details are requested regardless of work-item count, as long as the target is + // not the pipeline's first stage. + expect(validatePromotionStub.firstCall.args[4]).to.be.true; + }); + + test + .stdout() + .stderr() + .it('does not request combine details when promoting to the first stage', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + // The target stage is the pipeline's first stage, so work items have no source stage. + fetchPipelineStagesStub.resolves([{ Id: '1QVxx0000000003', Name: 'Integration', NextStageId: null }]); + computeFirstStageIdStub.returns('1QVxx0000000003'); + validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null }); + + await ValidateCommand.run([ + '-o', + 'testOrg', + '-i', + '1fkxx0000000001', + '-i', + '1fkxx0000000002', + '-t', + '1QVxx0000000003', + ]); + + // Combine details for the first stage NPE server-side (null source stage), so skip them + // regardless of work-item count. + expect(validatePromotionStub.firstCall.args[4]).to.be.false; + }); + test .stdout() .stderr()