Skip to content

Commit 193e9a2

Browse files
authored
Merge pull request #519 from salesforcecli/ashreya/command-enh
@W-23334293 - Support attaching projects in pipeline create
2 parents c564857 + c0c2354 commit 193e9a2

7 files changed

Lines changed: 174 additions & 13 deletions

File tree

command-snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"flags-dir",
1313
"json",
1414
"name",
15+
"project-id",
1516
"repo",
1617
"repo-owner",
1718
"repo-type",

messages/devops.pipeline.create.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ Bitbucket project key to associate with the repository. Optional when creating a
4242

4343
Name of a pipeline stage, in promotion order. Repeat the flag for each stage. Defaults to Integration, UAT, Staging, and Production.
4444

45+
# flags.project-id.summary
46+
47+
ID of a project to associate with the pipeline. Repeat the flag to associate multiple projects.
48+
4549
# examples
4650

4751
- 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
6468

6569
<%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://github.com/myorg/myrepo --stage Dev --stage QA --stage Prod
6670

71+
- Create a pipeline and associate one or more projects with it:
72+
73+
<%= config.bin %> <%= command.id %> --target-org my-devops-org --name "Release Pipeline" --repo https://github.com/myorg/myrepo --project-id 0Hn000000000001 --project-id 0Hn000000000002
74+
6775
# error.RepoTypeRequired
6876

6977
The --repo-type flag is required when using --create-repo. Specify --repo-type github or --repo-type bitbucket.

src/commands/devops/pipeline/create.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
6969
char: 's',
7070
multiple: true,
7171
}),
72+
'project-id': Flags.salesforceId({
73+
summary: messages.getMessage('flags.project-id.summary'),
74+
multiple: true,
75+
char: undefined,
76+
}),
7277
};
7378

7479
public async run(): Promise<CreatePipelineResult> {
@@ -110,6 +115,7 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
110115
bitbucketWorkspace: flags['bitbucket-workspace'],
111116
bitbucketProjectKey: flags['bitbucket-project-key'],
112117
stages: flags['stage'],
118+
projectIds: flags['project-id'],
113119
});
114120
} catch (error: unknown) {
115121
const errMsg = error instanceof Error ? error.message : String(error);
@@ -121,7 +127,7 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
121127
}
122128

123129
if (result.success) {
124-
this.printSuccessOutput(result, flags['repo'], org.getUsername());
130+
this.printSuccessOutput(result, flags['repo'], org.getUsername(), flags['project-id']);
125131
} else {
126132
this.error(`Failed to create pipeline: ${result.error ?? ''}`);
127133
}
@@ -163,22 +169,32 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
163169
}
164170
}
165171

166-
private printSuccessOutput(result: CreatePipelineResult, repoFlag: string, username: string | undefined): void {
172+
private printSuccessOutput(
173+
result: CreatePipelineResult,
174+
repoFlag: string,
175+
username: string | undefined,
176+
projectIds: string[] | undefined
177+
): void {
167178
if (result.repository?.created) {
168179
this.log(`Created repository: ${repoFlag} (${result.repository.repoType})`);
169180
}
170181
this.log(`Successfully created pipeline: ${result.name ?? ''}`);
171182
this.log(` Pipeline ID: ${result.pipelineId ?? ''}`);
172183
this.log(` Repository: ${result.repository?.repoUrl ?? ''} (${result.repository?.repoType ?? ''})`);
173184
this.log(` Status: ${result.status ?? 'Inactive'}`);
185+
if (projectIds && projectIds.length > 0) {
186+
this.log(` Projects: ${projectIds.join(', ')}`);
187+
}
174188
this.log(' Next steps:');
175189
const orgLabel = username ?? '<org>';
176190
const pipelineIdLabel = result.pipelineId ?? '<ID>';
177191
this.log(
178192
` Add pipeline stages: sf devops pipeline stage add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel}`
179193
);
180-
this.log(
181-
` Attach a project: sf devops pipeline project add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id <ID>`
182-
);
194+
if (!projectIds || projectIds.length === 0) {
195+
this.log(
196+
` Attach a project: sf devops pipeline project add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id <ID>`
197+
);
198+
}
183199
}
184200
}

src/commands/devops/promotion/validate.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Messages } from '@salesforce/core';
17+
import { Messages, Connection } from '@salesforce/core';
1818
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
1919
import {
2020
validatePromotion,
@@ -23,14 +23,32 @@ import {
2323
formatValidationDetails,
2424
hasSharedComponents,
2525
} from '../../../utils/promotionUtils.js';
26-
import { validateSalesforceId } from '../../../utils/soqlUtils.js';
26+
import { validateSalesforceId, normalizeSalesforceId } from '../../../utils/soqlUtils.js';
2727
import { resolveProjectIdFromWorkItem } from '../../../utils/prepareWorkItem.js';
28-
import { getPipelineIdForProject } from '../../../utils/pipelineUtils.js';
28+
import { getPipelineIdForProject, fetchPipelineStages, computeFirstStageId } from '../../../utils/pipelineUtils.js';
2929

3030
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
3131
const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.promotion.validate');
3232
const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors');
3333

34+
/**
35+
* Combine details describe how work items that share components could be merged before promotion.
36+
* We request them regardless of work-item count, except when promoting to the pipeline's first
37+
* stage: those work items come straight from dev branches and have no source stage, so Core's
38+
* combine-details path NPEs on a null source stage.
39+
*/
40+
async function shouldCheckCombineDetails(
41+
connection: Connection,
42+
pipelineId: string,
43+
targetStageId: string
44+
): Promise<boolean> {
45+
const stages = await fetchPipelineStages(connection, pipelineId);
46+
const firstStageId = computeFirstStageId(stages);
47+
const promotingToFirstStage =
48+
Boolean(firstStageId) && normalizeSalesforceId(targetStageId) === normalizeSalesforceId(firstStageId!);
49+
return !promotingToFirstStage;
50+
}
51+
3452
export type PromotionValidateResult = {
3553
success: boolean;
3654
errorType: string | null;
@@ -89,9 +107,11 @@ export default class DevopsPromotionValidate extends SfCommand<PromotionValidate
89107
throw error;
90108
}
91109

110+
const checkCombineDetails = await shouldCheckCombineDetails(connection, pipelineId, targetStageId);
111+
92112
let result: ValidatePromotionResult;
93113
try {
94-
result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, true);
114+
result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, checkCombineDetails);
95115
} catch (error: unknown) {
96116
const errMsg = error instanceof Error ? error.message : String(error);
97117
if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) {

src/utils/createPipeline.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type CreatePipelineParams = {
3333
bitbucketWorkspace?: string;
3434
bitbucketProjectKey?: string;
3535
stages?: string[];
36+
projectIds?: string[];
3637
};
3738

3839
export type CreatePipelineResult = {
@@ -119,8 +120,18 @@ export class GitHubOwnerNotFoundError extends Error {
119120
* POST /services/data/v{version}/connect/devops/pipelines
120121
*/
121122
export async function createPipeline(params: CreatePipelineParams): Promise<CreatePipelineResult> {
122-
const { connection, name, repo, repoType, createRepo, repoOwner, bitbucketWorkspace, bitbucketProjectKey, stages } =
123-
params;
123+
const {
124+
connection,
125+
name,
126+
repo,
127+
repoType,
128+
createRepo,
129+
repoOwner,
130+
bitbucketWorkspace,
131+
bitbucketProjectKey,
132+
stages,
133+
projectIds,
134+
} = params;
124135

125136
const path = `/services/data/v${connection.getApiVersion()}/connect/devops/pipelines`;
126137

@@ -132,6 +143,10 @@ export async function createPipeline(params: CreatePipelineParams): Promise<Crea
132143
stages: stageNames.map((stageName) => ({ name: stageName })),
133144
};
134145

146+
if (projectIds && projectIds.length > 0) {
147+
payload.projectIds = projectIds;
148+
}
149+
135150
if (createRepo) {
136151
payload.createVcsRepo = true;
137152
payload.vcsRepoName = repo;

test/commands/devops/promotion/validate.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe('devops promotion validate', () => {
2626
const validatePromotionStub = sinon.stub();
2727
const resolveProjectIdFromWorkItemStub = sinon.stub();
2828
const getPipelineIdForProjectStub = sinon.stub();
29+
const fetchPipelineStagesStub = sinon.stub();
30+
const computeFirstStageIdStub = sinon.stub();
2931
const mockConnection = { getApiVersion: () => '65.0' };
3032
const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection };
3133

@@ -39,6 +41,8 @@ describe('devops promotion validate', () => {
3941
},
4042
'../../../../src/utils/pipelineUtils.js': {
4143
getPipelineIdForProject: getPipelineIdForProjectStub,
44+
fetchPipelineStages: fetchPipelineStagesStub,
45+
computeFirstStageId: computeFirstStageIdStub,
4246
},
4347
});
4448
ValidateCommand = mod.default;
@@ -49,6 +53,11 @@ describe('devops promotion validate', () => {
4953
validatePromotionStub.reset();
5054
resolveProjectIdFromWorkItemStub.reset();
5155
getPipelineIdForProjectStub.reset();
56+
fetchPipelineStagesStub.reset();
57+
computeFirstStageIdStub.reset();
58+
// Default: target stage is not the pipeline's first stage, so combine details are requested.
59+
fetchPipelineStagesStub.resolves([]);
60+
computeFirstStageIdStub.returns(undefined);
5261
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5362
sandbox.stub(Org, 'create' as any).returns(mockOrg);
5463
});
@@ -85,17 +94,68 @@ describe('devops promotion validate', () => {
8594
test
8695
.stdout()
8796
.stderr()
88-
.it('requests combine details from the API', async () => {
97+
.it('requests combine details from the API for multiple work items', async () => {
8998
resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' });
9099
getPipelineIdForProjectStub.resolves('PIPE001');
91100
validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null });
92101

93-
await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']);
102+
await ValidateCommand.run([
103+
'-o',
104+
'testOrg',
105+
'-i',
106+
'1fkxx0000000001',
107+
'-i',
108+
'1fkxx0000000002',
109+
'-t',
110+
'1QVxx0000000003',
111+
]);
94112

95113
// checkCombineDetails (5th arg) must be true so the API returns shared-component info.
96114
expect(validatePromotionStub.firstCall.args[4]).to.be.true;
97115
});
98116

117+
test
118+
.stdout()
119+
.stderr()
120+
.it('requests combine details for a single work item promoted to a non-first stage', async () => {
121+
resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' });
122+
getPipelineIdForProjectStub.resolves('PIPE001');
123+
validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null });
124+
125+
await ValidateCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']);
126+
127+
// Combine details are requested regardless of work-item count, as long as the target is
128+
// not the pipeline's first stage.
129+
expect(validatePromotionStub.firstCall.args[4]).to.be.true;
130+
});
131+
132+
test
133+
.stdout()
134+
.stderr()
135+
.it('does not request combine details when promoting to the first stage', async () => {
136+
resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' });
137+
getPipelineIdForProjectStub.resolves('PIPE001');
138+
// The target stage is the pipeline's first stage, so work items have no source stage.
139+
fetchPipelineStagesStub.resolves([{ Id: '1QVxx0000000003', Name: 'Integration', NextStageId: null }]);
140+
computeFirstStageIdStub.returns('1QVxx0000000003');
141+
validatePromotionStub.resolves({ success: true, errorType: null, errorDetails: null, combineDetails: null });
142+
143+
await ValidateCommand.run([
144+
'-o',
145+
'testOrg',
146+
'-i',
147+
'1fkxx0000000001',
148+
'-i',
149+
'1fkxx0000000002',
150+
'-t',
151+
'1QVxx0000000003',
152+
]);
153+
154+
// Combine details for the first stage NPE server-side (null source stage), so skip them
155+
// regardless of work-item count.
156+
expect(validatePromotionStub.firstCall.args[4]).to.be.false;
157+
});
158+
99159
test
100160
.stdout()
101161
.stderr()

test/utils/createPipeline.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,47 @@ describe('createPipeline utilities', () => {
278278
]);
279279
});
280280

281+
it('includes projectIds when projects are provided', async () => {
282+
(connectionStub.request as sinon.SinonStub).resolves({
283+
id: '0XB000000000007',
284+
message: 'Created',
285+
status: 'Inactive',
286+
});
287+
(connectionStub.getApiVersion as sinon.SinonStub).returns('65.0');
288+
289+
await createPipeline({
290+
connection: connectionStub as unknown as Connection,
291+
name: 'Pipeline With Projects',
292+
repo: 'https://github.com/myorg/myrepo',
293+
repoType: 'github',
294+
projectIds: ['0Hn000000000001', '0Hn000000000002'],
295+
});
296+
297+
const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0];
298+
const body = JSON.parse(callArgs.body as string) as Record<string, unknown>;
299+
expect(body.projectIds).to.deep.equal(['0Hn000000000001', '0Hn000000000002']);
300+
});
301+
302+
it('omits projectIds when none are provided', async () => {
303+
(connectionStub.request as sinon.SinonStub).resolves({
304+
id: '0XB000000000008',
305+
message: 'Created',
306+
status: 'Inactive',
307+
});
308+
(connectionStub.getApiVersion as sinon.SinonStub).returns('65.0');
309+
310+
await createPipeline({
311+
connection: connectionStub as unknown as Connection,
312+
name: 'Pipeline No Projects',
313+
repo: 'https://github.com/myorg/myrepo',
314+
repoType: 'github',
315+
});
316+
317+
const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0];
318+
const body = JSON.parse(callArgs.body as string) as Record<string, unknown>;
319+
expect(body).to.not.have.property('projectIds');
320+
});
321+
281322
it('propagates API errors', async () => {
282323
(connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request'));
283324
(connectionStub.getApiVersion as sinon.SinonStub).returns('65.0');

0 commit comments

Comments
 (0)