Skip to content
Merged
3 changes: 3 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"api-version",
"deploy-all",
"flags-dir",
"force",
"json",
"skip-validation",
"stage-id",
Expand Down Expand Up @@ -181,6 +182,7 @@
"branch-name",
"create-vcs-branch",
"flags-dir",
"force",
"json",
"pipeline-id",
"stage-id",
Expand All @@ -205,6 +207,7 @@
"api-version",
"environment-name",
"flags-dir",
"force",
"json",
"no-browser",
"org-type",
Expand Down
12 changes: 12 additions & 0 deletions messages/devops.promote.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ ID of the source pipeline stage whose approved work items will be promoted. Mutu

Skip pre-promote validation. By default, promotion is validated before proceeding to prevent promoting work items without an associated PR. Use this flag to bypass validation.

# flags.force.summary

Submit the promotion even if another promotion to the same target stage is already in progress. By default, the command blocks to avoid creating duplicate promotions.

# error.PromotionInFlight

A promotion to this stage is already in progress (request token%s: %s). Submitting now can create a duplicate promotion. Wait for it to finish (check with "%s devops request status --request-token <token>"), or pass --force to promote anyway.

# warn.InFlightCheckSkipped

Couldn't check for in-flight promotions to this stage, so a duplicate promotion may not be blocked: %s

# error.NoModeFlag

Provide either --work-item-id to promote specific work items or --stage-id to promote all approved work items from a stage.
Expand Down
18 changes: 17 additions & 1 deletion messages/devops.stage.branch.add.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Add a source code repository branch to a pipeline stage.
# description

By default, the branch must exist in the repository. Use --create-vcs-branch to create a branch if it doesn't exist.
Each pipeline stage supports only one branch. Adding a branch replaces any existing branch linked to the pipeline stage.
Each pipeline stage supports only one branch. If the stage already has a branch, the command blocks so the existing branch isn't orphaned; pass --force to replace it.

# flags.pipeline-id.summary

Expand All @@ -23,6 +23,10 @@ Name of the repository branch to assign to the stage.

Create the branch in the remote repository if it doesn't already exist.

# flags.force.summary

Replace the stage's existing branch. By default, the command blocks if the stage already has a branch to avoid orphaning it. With this flag, the new branch is associated and the previous branch record is removed if no other stage references it.

# examples

- Add an existing branch to a stage:
Expand All @@ -44,3 +48,15 @@ You must set up a branch on stage "%s" before configuring stage "%s". Branches m
# error.BranchAttachFailed

Failed to associate branch with stage: %s

# error.BranchAlreadyExists

Stage "%s" already has a branch ("%s"). Adding another would leave the existing one orphaned. Remove it first with "%s devops stage branch delete", or pass --force to replace it.

# info.ReplacedBranchRemoved

Removed the stage's previous branch "%s".

# warn.ReplacedBranchCleanupFailed

Associated the new branch, but couldn't remove the previous branch record (%s). Remove it manually if it's no longer needed.
16 changes: 16 additions & 0 deletions messages/devops.stage.environment.add.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Type of the Salesforce org. Valid values: Production, Sandbox.

Don't auto-open the browser for OAuth authentication. The redirect URL is printed for manual use.

# flags.force.summary

Replace the stage's existing environment. By default, the command blocks if the stage already has an environment to avoid orphaning it. With this flag, the new environment is added first and the previous one is removed only after the new environment is associated successfully.

# examples

- Add a production environment to a stage using its ID:
Expand Down Expand Up @@ -60,6 +64,18 @@ Pipeline stage "%s" doesn't exist in pipeline "%s". Check the stage ID and try a

Failed to create environment for stage: %s

# error.EnvironmentAlreadyExists

Stage "%s" already has an environment ("%s", %s). Adding another would leave the existing one orphaned. Remove it first with "%s devops stage environment delete", or pass --force to replace it.

# info.ReplacedEnvironmentRemoved

Removed the stage's previous environment "%s".

# warn.ReplacedEnvironmentCleanupFailed

Associated the new environment, but couldn't remove the previous environment record (%s). Remove it manually if it's no longer needed.

# error.AuthTimeout

Authentication timed out. The environment was created but not yet authenticated. Re-run the command or authenticate manually via the org's DevOps Center setup.
Expand Down
19 changes: 19 additions & 0 deletions schemas/devops-pipeline-create.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
"repository": {
"$ref": "#/definitions/RepoInfo"
},
"stages": {
"type": "array",
"items": {
"$ref": "#/definitions/PipelineStageInfo"
}
},
"error": {
"type": "string"
}
Expand All @@ -42,6 +48,19 @@
},
"required": ["repoUrl", "repoType", "created"],
"additionalProperties": false
},
"PipelineStageInfo": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
}
}
6 changes: 6 additions & 0 deletions src/commands/devops/pipeline/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ export default class DevopsPipelineCreate extends SfCommand<CreatePipelineResult
if (projectIds && projectIds.length > 0) {
this.log(` Projects: ${projectIds.join(', ')}`);
}
if (result.stages && result.stages.length > 0) {
this.log(' Stages:');
for (const stage of result.stages) {
this.log(` ${stage.name ?? ''} (${stage.id})`);
}
}
this.log(' Next steps:');
const orgLabel = username ?? '<org>';
const pipelineIdLabel = result.pipelineId ?? '<ID>';
Expand Down
38 changes: 37 additions & 1 deletion src/commands/devops/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@

import { Connection, Messages } from '@salesforce/core';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { promoteStage, PromoteStageResult } from '../../utils/promoteStage.js';
import {
promoteStage,
PromoteStageResult,
findInFlightPromotions,
InFlightPromotion,
} from '../../utils/promoteStage.js';
import { resolveProjectIdFromWorkItem } from '../../utils/prepareWorkItem.js';
import { getPipelineIdForProject } from '../../utils/pipelineUtils.js';
import { deployAll, testLevel as testLevelFlag, specificTestsNoChar } from '../../common/flags/promote/promoteFlags.js';
Expand Down Expand Up @@ -80,6 +85,10 @@ export default class DevopsPromote extends SfCommand<PromoteResult> {
summary: messages.getMessage('flags.skip-validation.summary'),
default: false,
}),
force: Flags.boolean({
summary: messages.getMessage('flags.force.summary'),
default: false,
}),
};

private static async assertWorkItemsPromotable(connection: Connection, workItemIds: string[]): Promise<string[]> {
Expand Down Expand Up @@ -168,6 +177,10 @@ export default class DevopsPromote extends SfCommand<PromoteResult> {
}
}

if (!flags.force) {
await this.assertNoInFlightPromotion(connection, targetStageId);
}

if (!flags['skip-validation']) {
await this.runValidation(connection, pipelineId, resolvedWorkItemIds, targetStageId, !workItemIds?.length);
}
Expand Down Expand Up @@ -211,6 +224,29 @@ export default class DevopsPromote extends SfCommand<PromoteResult> {
};
}

/**
* Blocks promotion if another promotion to the same target stage is already in flight, to
* avoid creating a duplicate. Scoped to the target stage (and therefore its pipeline), so
* promotions in other stages or pipelines are unaffected. Best-effort: if the check can't
* run, the promotion proceeds as before.
*/
private async assertNoInFlightPromotion(connection: Connection, targetStageId: string): Promise<void> {
let inFlight: InFlightPromotion[];
try {
inFlight = await findInFlightPromotions(connection, targetStageId);
} catch (err) {
// Guard failure must not break the existing promote flow, but surface it so operators can see
// the duplicate-promotion check was skipped rather than silently passing.
this.warn(messages.getMessage('warn.InFlightCheckSkipped', [err instanceof Error ? err.message : String(err)]));
return;
}
if (inFlight.length > 0) {
const tokens = inFlight.map((p) => p.requestToken).filter(Boolean);
const plural = tokens.length > 1 ? 's' : '';
this.error(messages.getMessage('error.PromotionInFlight', [plural, tokens.join(', '), this.config.bin]));
}
}

private async runValidation(
connection: Connection,
pipelineId: string,
Expand Down
68 changes: 66 additions & 2 deletions src/commands/devops/stage/branch/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,27 @@
* limitations under the License.
*/

import { Messages, Org } from '@salesforce/core';
import { Connection, Messages, Org } from '@salesforce/core';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { addStageBranch, AddStageBranchResult } from '../../../../utils/addStageBranch.js';
import {
addStageBranch,
AddStageBranchResult,
ExistingStageBranch,
getStageBranch,
deleteOrphanedBranch,
} from '../../../../utils/addStageBranch.js';
import { fetchPipelineStages } from '../../../../utils/pipelineUtils.js';
import { PipelineStageRecord } from '../../../../utils/types.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.stage.branch.add');
const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors');

// The platform stores created branch names with a "$$" marker prefix; strip it for display.
function displayBranchName(existingBranch: ExistingStageBranch): string {
return (existingBranch.branchName ?? existingBranch.branchId).replace(/^\$\$/, '');
}

export default class DevopsStageBranchAdd extends SfCommand<AddStageBranchResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand Down Expand Up @@ -51,6 +62,10 @@ export default class DevopsStageBranchAdd extends SfCommand<AddStageBranchResult
summary: messages.getMessage('flags.create-vcs-branch.summary'),
default: false,
}),
force: Flags.boolean({
summary: messages.getMessage('flags.force.summary'),
default: false,
}),
};

public async run(): Promise<AddStageBranchResult> {
Expand Down Expand Up @@ -85,6 +100,8 @@ export default class DevopsStageBranchAdd extends SfCommand<AddStageBranchResult
}
}

const existingBranch = await this.guardExistingBranch(connection, stageId, flags.force);

let result: AddStageBranchResult;
try {
result = await addStageBranch({
Expand All @@ -102,6 +119,53 @@ export default class DevopsStageBranchAdd extends SfCommand<AddStageBranchResult
throw error;
}

if (existingBranch && result.success) {
await this.cleanupReplacedBranch(connection, existingBranch);
}

return this.logResult(result, stageId, pipelineId);
}

/**
* A stage holds only one branch (DevopsPipelineStage.SourceCodeRepositoryBranchId). Adding a new
* one re-points the lookup and orphans the old SourceCodeRepositoryBranch record, so block by
* default and require --force to replace it. Returns the existing branch (when present) so the
* caller can remove the orphaned record after the new branch is associated.
*/
private async guardExistingBranch(
connection: Connection,
stageId: string,
force: boolean
): Promise<ExistingStageBranch | undefined> {
const existingBranch = await getStageBranch(connection, stageId);
if (!existingBranch) {
return undefined;
}
if (!force) {
this.error(
messages.getMessage('error.BranchAlreadyExists', [stageId, displayBranchName(existingBranch), this.config.bin])
);
}
return existingBranch;
}

/**
* Removes the branch record the stage was re-pointed away from, unless another stage still
* references it. Best-effort: a cleanup failure is surfaced as a warning and does not fail the
* command, since the new branch has already been associated successfully.
*/
private async cleanupReplacedBranch(connection: Connection, existingBranch: ExistingStageBranch): Promise<void> {
try {
const deleted = await deleteOrphanedBranch(connection, existingBranch.branchId);
if (deleted) {
this.log(messages.getMessage('info.ReplacedBranchRemoved', [displayBranchName(existingBranch)]));
}
} catch {
this.warn(messages.getMessage('warn.ReplacedBranchCleanupFailed', [existingBranch.branchId]));
}
}

private logResult(result: AddStageBranchResult, stageId: string, pipelineId: string): AddStageBranchResult {
if (result.success) {
const action = result.branchCreated ? 'Created branch and associated it' : 'Successfully associated branch';
this.log(`${action} with the stage.`);
Expand Down
Loading
Loading