Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"flags-dir",
"json",
"name",
"project-id",
"repo",
"repo-owner",
"repo-type",
Expand Down
8 changes: 8 additions & 0 deletions messages/devops.pipeline.create.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
26 changes: 21 additions & 5 deletions src/commands/devops/pipeline/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
char: 's',
multiple: true,
}),
'project-id': Flags.salesforceId({
summary: messages.getMessage('flags.project-id.summary'),
multiple: true,
char: undefined,
}),
};

public async run(): Promise<CreatePipelineResult> {
Expand Down Expand Up @@ -110,6 +115,7 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
bitbucketWorkspace: flags['bitbucket-workspace'],
bitbucketProjectKey: flags['bitbucket-project-key'],
stages: flags['stage'],
projectIds: flags['project-id'],
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
Expand All @@ -121,7 +127,7 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
}

if (result.success) {
this.printSuccessOutput(result, flags['repo'], org.getUsername());
this.printSuccessOutput(result, flags['repo'], org.getUsername(), flags['project-id']);
} else {
this.error(`Failed to create pipeline: ${result.error ?? ''}`);
}
Expand Down Expand Up @@ -163,22 +169,32 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
}
}

private printSuccessOutput(result: CreatePipelineResult, repoFlag: string, username: string | undefined): void {
private printSuccessOutput(
result: CreatePipelineResult,
repoFlag: string,
username: string | undefined,
projectIds: 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'}`);
if (projectIds && projectIds.length > 0) {
this.log(` Projects: ${projectIds.join(', ')}`);
}
this.log(' Next steps:');
const orgLabel = username ?? '<org>';
const pipelineIdLabel = result.pipelineId ?? '<ID>';
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 <ID>`
);
if (!projectIds || projectIds.length === 0) {
this.log(
` Attach a project: sf devops pipeline project add --target-org ${orgLabel} --pipeline-id ${pipelineIdLabel} --project-id <ID>`
);
}
}
}
28 changes: 24 additions & 4 deletions src/commands/devops/promotion/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean> {
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;
Expand Down Expand Up @@ -89,9 +107,11 @@ export default class DevopsPromotionValidate extends SfCommand<PromotionValidate
throw error;
}

const checkCombineDetails = await shouldCheckCombineDetails(connection, pipelineId, targetStageId);

let result: ValidatePromotionResult;
try {
result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, true);
result = await validatePromotion(connection, pipelineId, workItemIds, targetStageId, checkCombineDetails);
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) {
Expand Down
19 changes: 17 additions & 2 deletions src/utils/createPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type CreatePipelineParams = {
bitbucketWorkspace?: string;
bitbucketProjectKey?: string;
stages?: string[];
projectIds?: string[];
};

export type CreatePipelineResult = {
Expand Down Expand Up @@ -119,8 +120,18 @@ export class GitHubOwnerNotFoundError extends Error {
* POST /services/data/v{version}/connect/devops/pipelines
*/
export async function createPipeline(params: CreatePipelineParams): Promise<CreatePipelineResult> {
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`;

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

if (projectIds && projectIds.length > 0) {
payload.projectIds = projectIds;
}

if (createRepo) {
payload.createVcsRepo = true;
payload.vcsRepoName = repo;
Expand Down
64 changes: 62 additions & 2 deletions test/commands/devops/promotion/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ describe('devops promotion validate', () => {
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 };

Expand All @@ -39,6 +41,8 @@ describe('devops promotion validate', () => {
},
'../../../../src/utils/pipelineUtils.js': {
getPipelineIdForProject: getPipelineIdForProjectStub,
fetchPipelineStages: fetchPipelineStagesStub,
computeFirstStageId: computeFirstStageIdStub,
},
});
ValidateCommand = mod.default;
Expand All @@ -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);
});
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions test/utils/createPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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');
Expand Down
Loading