diff --git a/command-snapshot.json b/command-snapshot.json index 89c83d54..8740e6cf 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -118,6 +118,7 @@ "api-version", "deploy-all", "flags-dir", + "force", "json", "skip-validation", "stage-id", @@ -181,6 +182,7 @@ "branch-name", "create-vcs-branch", "flags-dir", + "force", "json", "pipeline-id", "stage-id", @@ -205,6 +207,7 @@ "api-version", "environment-name", "flags-dir", + "force", "json", "no-browser", "org-type", diff --git a/messages/devops.promote.md b/messages/devops.promote.md index 277f87da..3c1e6db7 100644 --- a/messages/devops.promote.md +++ b/messages/devops.promote.md @@ -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 "), 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. diff --git a/messages/devops.stage.branch.add.md b/messages/devops.stage.branch.add.md index 7c8c2140..23d44bb2 100644 --- a/messages/devops.stage.branch.add.md +++ b/messages/devops.stage.branch.add.md @@ -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 @@ -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: @@ -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. diff --git a/messages/devops.stage.environment.add.md b/messages/devops.stage.environment.add.md index 9130687e..783f9b6d 100644 --- a/messages/devops.stage.environment.add.md +++ b/messages/devops.stage.environment.add.md @@ -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: @@ -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. diff --git a/schemas/devops-pipeline-create.json b/schemas/devops-pipeline-create.json index 042b87e0..49f53209 100644 --- a/schemas/devops-pipeline-create.json +++ b/schemas/devops-pipeline-create.json @@ -20,6 +20,12 @@ "repository": { "$ref": "#/definitions/RepoInfo" }, + "stages": { + "type": "array", + "items": { + "$ref": "#/definitions/PipelineStageInfo" + } + }, "error": { "type": "string" } @@ -42,6 +48,19 @@ }, "required": ["repoUrl", "repoType", "created"], "additionalProperties": false + }, + "PipelineStageInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } } } diff --git a/src/commands/devops/pipeline/create.ts b/src/commands/devops/pipeline/create.ts index 12aa4e53..8593bca7 100644 --- a/src/commands/devops/pipeline/create.ts +++ b/src/commands/devops/pipeline/create.ts @@ -185,6 +185,12 @@ export default class DevopsPipelineCreate extends SfCommand 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 ?? ''; const pipelineIdLabel = result.pipelineId ?? ''; diff --git a/src/commands/devops/promote.ts b/src/commands/devops/promote.ts index 97eac967..b091cf2b 100644 --- a/src/commands/devops/promote.ts +++ b/src/commands/devops/promote.ts @@ -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'; @@ -80,6 +85,10 @@ export default class DevopsPromote extends SfCommand { 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 { @@ -168,6 +177,10 @@ export default class DevopsPromote extends SfCommand { } } + if (!flags.force) { + await this.assertNoInFlightPromotion(connection, targetStageId); + } + if (!flags['skip-validation']) { await this.runValidation(connection, pipelineId, resolvedWorkItemIds, targetStageId, !workItemIds?.length); } @@ -211,6 +224,29 @@ export default class DevopsPromote extends SfCommand { }; } + /** + * 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 { + 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, diff --git a/src/commands/devops/stage/branch/add.ts b/src/commands/devops/stage/branch/add.ts index 5ada2374..5f3f8151 100644 --- a/src/commands/devops/stage/branch/add.ts +++ b/src/commands/devops/stage/branch/add.ts @@ -14,9 +14,15 @@ * 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'; @@ -24,6 +30,11 @@ 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 { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -51,6 +62,10 @@ export default class DevopsStageBranchAdd extends SfCommand { @@ -85,6 +100,8 @@ export default class DevopsStageBranchAdd extends SfCommand { + 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 { + 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.`); diff --git a/src/commands/devops/stage/environment/add.ts b/src/commands/devops/stage/environment/add.ts index 72d437f9..709a1e7d 100644 --- a/src/commands/devops/stage/environment/add.ts +++ b/src/commands/devops/stage/environment/add.ts @@ -15,9 +15,16 @@ */ import { execFile } from 'node:child_process'; -import { Messages, Org } from '@salesforce/core'; +import { Connection, Messages, Org } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { addStageEnvironment, AddStageEnvironmentResult, OrgType } from '../../../../utils/addStageEnvironment.js'; +import { + addStageEnvironment, + AddStageEnvironmentResult, + OrgType, + getStageEnvironment, + deleteOrphanedEnvironment, + ExistingStageEnvironment, +} from '../../../../utils/addStageEnvironment.js'; import { fetchPipelineStages } from '../../../../utils/pipelineUtils.js'; import { PipelineStageRecord } from '../../../../utils/types.js'; import { validateSalesforceId } from '../../../../utils/soqlUtils.js'; @@ -98,6 +105,10 @@ export default class DevopsStageEnvironmentAdd extends SfCommand { @@ -134,6 +145,8 @@ export default class DevopsStageEnvironmentAdd extends SfCommand { + const existingEnvironment = await getStageEnvironment(connection, stageId); + if (!existingEnvironment) { + return undefined; + } + if (!force) { + this.error( + messages.getMessage('error.EnvironmentAlreadyExists', [ + stageId, + existingEnvironment.environmentName ?? existingEnvironment.environmentId, + existingEnvironment.environmentId, + this.config.bin, + ]) + ); + } + return existingEnvironment; + } + + /** + * Removes the environment 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 environment has already been associated successfully. + */ + private async cleanupReplacedEnvironment( + connection: Connection, + existingEnvironment: ExistingStageEnvironment + ): Promise { + try { + const deleted = await deleteOrphanedEnvironment(connection, existingEnvironment.environmentId); + if (deleted) { + this.log( + messages.getMessage('info.ReplacedEnvironmentRemoved', [ + existingEnvironment.environmentName ?? existingEnvironment.environmentId, + ]) + ); + } + } catch { + this.warn(messages.getMessage('warn.ReplacedEnvironmentCleanupFailed', [existingEnvironment.environmentId])); + } + } + + private logResult( + result: AddStageEnvironmentResult, + stageId: string, + orgType: OrgType, + pipelineId: string + ): AddStageEnvironmentResult { if (result.success) { this.log(messages.getMessage('info.Success')); this.log(` Stage ID: ${stageId}`); diff --git a/src/commands/devops/work-item/create.ts b/src/commands/devops/work-item/create.ts index f1029cf9..eadef1bf 100644 --- a/src/commands/devops/work-item/create.ts +++ b/src/commands/devops/work-item/create.ts @@ -71,6 +71,7 @@ export default class DevopsWorkItemCreate extends SfCommand ({ + ID: wi.id, Name: wi.name, Subject: wi.subject ?? '', Status: wi.status, @@ -74,7 +75,7 @@ export default class DevopsWorkItemList extends SfCommand { + validateSalesforceId(stageId, 'stage'); + const result = await connection.query<{ + SourceCodeRepositoryBranchId: string | null; + SourceCodeRepositoryBranch: { Name: string } | null; + }>( + `SELECT SourceCodeRepositoryBranchId, SourceCodeRepositoryBranch.Name FROM DevopsPipelineStage WHERE Id = '${stageId}' LIMIT 1` + ); + const record = (result.records ?? [])[0]; + if (!record?.SourceCodeRepositoryBranchId) { + return undefined; + } + return { branchId: record.SourceCodeRepositoryBranchId, branchName: record.SourceCodeRepositoryBranch?.Name }; +} + +/** + * Deletes a SourceCodeRepositoryBranch record only when no pipeline stage still references it. + * Returns true when the record was deleted, false when it is still referenced (left intact). + * + * Used to clean up the branch a stage was re-pointed away from during a --force replace, so the old + * record isn't orphaned in the org. The reference check keeps a branch shared by another stage safe. + */ +export async function deleteOrphanedBranch(connection: Connection, branchId: string): Promise { + validateSalesforceId(branchId, 'branch'); + const refs = await connection.query<{ Id: string }>( + `SELECT Id FROM DevopsPipelineStage WHERE SourceCodeRepositoryBranchId = '${branchId}' LIMIT 1` + ); + if ((refs.records ?? []).length > 0) { + return false; + } + await connection.sobject('SourceCodeRepositoryBranch').delete(branchId); + return true; +} + export type AddStageBranchResult = { success: boolean; stageId: string; diff --git a/src/utils/addStageEnvironment.ts b/src/utils/addStageEnvironment.ts index 807acdf8..d49906b1 100644 --- a/src/utils/addStageEnvironment.ts +++ b/src/utils/addStageEnvironment.ts @@ -15,9 +15,58 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; export type OrgType = 'Production' | 'Sandbox'; +export type ExistingStageEnvironment = { + environmentId: string; + environmentName?: string; +}; + +/** + * Returns the environment currently associated with a pipeline stage, if any. + * + * A stage references its environment via DevopsPipelineStage.DevOpsEnvironmentId. Adding a new + * environment re-points that lookup, so callers must check for an existing environment first to + * avoid orphaning the old DevopsEnvironment record. + */ +export async function getStageEnvironment( + connection: Connection, + stageId: string +): Promise { + validateSalesforceId(stageId, 'stage'); + const result = await connection.query<{ + DevOpsEnvironmentId: string | null; + DevOpsEnvironment: { Name: string } | null; + }>(`SELECT DevOpsEnvironmentId, DevOpsEnvironment.Name FROM DevopsPipelineStage WHERE Id = '${stageId}' LIMIT 1`); + const record = (result.records ?? [])[0]; + if (!record?.DevOpsEnvironmentId) { + return undefined; + } + return { environmentId: record.DevOpsEnvironmentId, environmentName: record.DevOpsEnvironment?.Name }; +} + +/** + * Deletes a DevopsEnvironment record only when no pipeline stage still references it. + * Returns true when the record was deleted, false when it is still referenced (left intact). + * + * Used to clean up the environment a stage was re-pointed away from during a --force replace, so the + * old record isn't orphaned in the org. The reference check keeps an environment shared by another + * stage safe. + */ +export async function deleteOrphanedEnvironment(connection: Connection, environmentId: string): Promise { + validateSalesforceId(environmentId, 'environment'); + const refs = await connection.query<{ Id: string }>( + `SELECT Id FROM DevopsPipelineStage WHERE DevOpsEnvironmentId = '${environmentId}' LIMIT 1` + ); + if ((refs.records ?? []).length > 0) { + return false; + } + await connection.sobject('DevopsEnvironment').delete(environmentId); + return true; +} + const ORG_TYPE_API_MAP: Record = { Production: 'PRODUCTION', Sandbox: 'SANDBOX', diff --git a/src/utils/createPipeline.ts b/src/utils/createPipeline.ts index 7c797994..a7d1cfc7 100644 --- a/src/utils/createPipeline.ts +++ b/src/utils/createPipeline.ts @@ -16,6 +16,8 @@ import { execSync } from 'node:child_process'; import { Connection } from '@salesforce/core'; +import { PipelineStageRecord } from './types.js'; +import { fetchPipelineStages, computeFirstStageId } from './pipelineUtils.js'; export type RepoInfo = { repoUrl: string; @@ -23,6 +25,11 @@ export type RepoInfo = { created: boolean; }; +export type PipelineStageInfo = { + id: string; + name?: string; +}; + export type CreatePipelineParams = { connection: Connection; name: string; @@ -42,6 +49,7 @@ export type CreatePipelineResult = { name?: string; status?: string; repository?: RepoInfo; + stages?: PipelineStageInfo[]; error?: string; }; @@ -181,5 +189,43 @@ export async function createPipeline(params: CreatePipelineParams): Promise { + try { + const stages = await fetchPipelineStages(connection, pipelineId); + return orderStages(stages).map((s) => ({ id: s.Id, name: s.Name })); + } catch { + return undefined; + } +} + +/** + * Orders stages from the first stage following the NextStageId chain. Falls back to + * the original query order if the chain can't be resolved into a single sequence. + */ +function orderStages(stages: PipelineStageRecord[]): PipelineStageRecord[] { + const firstId = computeFirstStageId(stages); + if (!firstId) return stages; + const ordered: PipelineStageRecord[] = []; + const seen = new Set(); + let currentId: string | undefined = firstId; + while (currentId && !seen.has(currentId)) { + const stage = stages.find((s) => s.Id === currentId); + if (!stage) break; + ordered.push(stage); + seen.add(currentId); + currentId = stage.NextStageId ?? undefined; + } + return ordered.length === stages.length ? ordered : stages; +} diff --git a/src/utils/createWorkItem.ts b/src/utils/createWorkItem.ts index 28ca77a3..bb817bd4 100644 --- a/src/utils/createWorkItem.ts +++ b/src/utils/createWorkItem.ts @@ -51,28 +51,33 @@ export async function createWorkItem(params: CreateWorkItemParams): Promise) ?? {}; return { success: true, - workItemId: (data.id ?? data.Id) as string | undefined, - workItemName: (data.name ?? data.Name) as string | undefined, + workItemId: (data.workItemId ?? data.id ?? data.Id) as string | undefined, + workItemName: (data.workItemName ?? data.name ?? data.Name) as string | undefined, subject: (data.subject ?? data.Subject ?? subject) as string, }; } catch (error: unknown) { - const err = error as Record & { response?: { data?: unknown }; body?: unknown; message?: unknown }; - const data: unknown = (err.response?.data ?? err.body ?? err) as unknown; - const message: unknown = - (typeof data === 'object' && - data !== null && - ((data as Record).message ?? - (data as Record).error ?? - (data as Record).errorDescription)) || - err.message || - 'Unknown error'; - const details = - typeof data === 'object' && data !== null && Array.isArray((data as Record).body) - ? ((data as Record).body as string[]).join('; ') - : undefined; - return { - success: false, - error: details ? `${String(message)}: ${details}` : String(message), - }; + return { success: false, error: parseErrorMessage(error) }; } } + +/** + * Extracts a human-readable error message from a failed work item request, + * unwrapping the various shapes the Connect API and jsforce may return. + */ +function parseErrorMessage(error: unknown): string { + const err = error as Record & { response?: { data?: unknown }; body?: unknown; message?: unknown }; + const data: unknown = (err.response?.data ?? err.body ?? err) as unknown; + const message: unknown = + (typeof data === 'object' && + data !== null && + ((data as Record).message ?? + (data as Record).error ?? + (data as Record).errorDescription)) || + err.message || + 'Unknown error'; + const details = + typeof data === 'object' && data !== null && Array.isArray((data as Record).body) + ? ((data as Record).body as string[]).join('; ') + : undefined; + return details ? `${String(message)}: ${details}` : String(message); +} diff --git a/src/utils/promoteStage.ts b/src/utils/promoteStage.ts index 3e3b0e95..1623a2c3 100644 --- a/src/utils/promoteStage.ts +++ b/src/utils/promoteStage.ts @@ -15,6 +15,7 @@ */ import { Connection } from '@salesforce/core'; +import { validateSalesforceId } from './soqlUtils.js'; export type PromoteStageParams = { connection: Connection; @@ -81,3 +82,47 @@ export async function promoteStage(params: PromoteStageParams): Promise { + validateSalesforceId(targetStageId, 'target stage'); + const statusList = IN_FLIGHT_PROMOTION_STATUSES.join("', '"); + const result = await connection.query( + `SELECT Id, Status, RequestInfoId, RequestInfo.RequestToken FROM DevopsPipelnStgProm WHERE PipelineStageId = '${targetStageId}' AND Status IN ('${statusList}') LIMIT 10` + ); + return (result.records ?? []).map((r) => ({ + id: r.Id, + requestToken: r.RequestInfo?.RequestToken ?? r.RequestInfoId ?? '', + status: r.Status, + })); +} diff --git a/src/utils/updateWorkItem.ts b/src/utils/updateWorkItem.ts index d54e27a5..5d613969 100644 --- a/src/utils/updateWorkItem.ts +++ b/src/utils/updateWorkItem.ts @@ -57,6 +57,46 @@ export function toApiStatus(status: string): string { return apiStatus; } +/** + * Allowed current statuses for each target status a user can set. A work item may only move + * to IN_PROGRESS while it is NEW, and to READY_TO_PROMOTE while it is IN_REVIEW. Target statuses + * absent from this map have no precondition. + */ +export const STATUS_TRANSITION_PRECONDITIONS: Record = { + IN_PROGRESS: ['NEW'], + READY_TO_PROMOTE: ['IN_REVIEW'], +}; + +/** + * Verifies that the work item's current status permits the requested transition. Throws with a + * descriptive error when it does not. Only runs a query when the target status has a precondition, + * so subject/description-only updates are unaffected. + */ +export async function assertStatusTransitionAllowed( + connection: Connection, + workItemId: string, + targetApiStatus: string +): Promise { + const allowedFrom = STATUS_TRANSITION_PRECONDITIONS[targetApiStatus]; + if (!allowedFrom) { + return; + } + validateSalesforceId(workItemId, 'work item'); + const result = await connection.query<{ Status: string }>( + `SELECT Status FROM WorkItem WHERE Id = '${workItemId}' LIMIT 1` + ); + const current = result.records?.[0]?.Status; + if (!current) { + throw new Error(`Work item with ID '${workItemId}' not found.`); + } + if (!allowedFrom.includes(current)) { + const allowed = allowedFrom.join(', '); + throw new Error( + `Cannot change status to ${targetApiStatus}: the work item is currently ${current}, but this transition is only allowed from ${allowed}.` + ); + } +} + export async function resolveWorkItemByName(connection: Connection, workItemName: string): Promise { const result = await connection.query<{ Id: string; DevopsProjectId: string }>( `SELECT Id, DevopsProjectId FROM WorkItem WHERE Name = '${escapeSOQL(workItemName)}' LIMIT 1` @@ -81,32 +121,81 @@ export async function resolveProjectIdForWorkItem(connection: Connection, workIt } /** - * Updates fields on a DevOps Center work item via the Connect API. - * PATCH /services/data/v{version}/connect/devops/projects/{projectId}/workitem/{workItemId} + * Updates a DevOps Center work item. + * + * Subject and description are plain WorkItem sObject fields — the DevOps Center connect work item + * endpoint does not accept them — so they are written via the sObject API. Status changes go + * through the connect endpoint (PATCH /connect/devops/projects/{projectId}/workitem/{workItemId}), + * which is the only field that endpoint accepts, so platform-side status handling runs. + * + * The two writes hit different APIs with no shared transaction. Subject/description are committed + * first; if the status call then fails, this returns `{ success: false }` with an error that names + * the fields that did persist, rather than throwing and leaving the committed changes unreported. */ export async function updateWorkItem(params: UpdateWorkItemParams): Promise { const { connection, workItemId, projectId, status, subject, description } = params; - const path = `/services/data/v${connection.getApiVersion()}/connect/devops/projects/${projectId}/workitem/${workItemId}`; - const payload: Record = {}; - if (status !== undefined) payload.status = toApiStatus(status); - if (subject !== undefined) payload.subject = subject; - if (description !== undefined) payload.description = description; + // Validate the status transition before writing any field. This guard is read-only, so running it + // first avoids persisting subject/description when the status change would be rejected — otherwise + // a combined --subject/--status call could silently keep the subject change after throwing. + let apiStatus: string | undefined; + if (status !== undefined) { + apiStatus = toApiStatus(status); + await assertStatusTransitionAllowed(connection, workItemId, apiStatus); + } - const response = await connection.request({ - method: 'PATCH', - url: path, - body: JSON.stringify(payload), - headers: { 'Content-Type': 'application/json' }, - }); + if (subject !== undefined || description !== undefined) { + const fields: { Id: string; Subject?: string; Description?: string } = { Id: workItemId }; + if (subject !== undefined) fields.Subject = subject; + if (description !== undefined) fields.Description = description; + const saveResult = await connection.sobject('WorkItem').update(fields); + if (!saveResult.success) { + const errorMsg = saveResult.errors + .map((e) => (typeof e === 'string' ? e : e.message)) + .filter(Boolean) + .join('; '); + return { success: false, workItemId, error: errorMsg || 'Failed to update work item fields.' }; + } + } + + if (apiStatus) { + const path = `/services/data/v${connection.getApiVersion()}/connect/devops/projects/${projectId}/workitem/${workItemId}`; + try { + await connection.request({ + method: 'PATCH', + url: path, + body: JSON.stringify({ status: apiStatus }), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: unknown) { + // The subject/description sObject write above has already committed. The two writes go to + // different APIs with no shared transaction, so a status failure here can't roll them back. + // Report a partial success instead of throwing, so the caller can tell the user exactly which + // fields persisted rather than surfacing a bare error that hides the committed changes. + const statusError = error instanceof Error ? error.message : String(error); + const persisted = [ + subject !== undefined ? 'subject' : undefined, + description !== undefined ? 'description' : undefined, + ].filter(Boolean); + const persistedNote = + persisted.length > 0 + ? ` The ${persisted.join(' and ')} ${persisted.length > 1 ? 'changes were' : 'change was'} already saved.` + : ''; + return { + success: false, + workItemId, + subject, + description, + error: `Failed to update work item status: ${statusError}.${persistedNote}`, + }; + } + } - const data = (response as Record) ?? {}; return { success: true, workItemId, - status: status !== undefined ? ((data.status ?? data.Status ?? status) as string) : undefined, - subject: subject !== undefined ? ((data.subject ?? data.Subject ?? subject) as string) : undefined, - description: - description !== undefined ? ((data.description ?? data.Description ?? description) as string) : undefined, + status: apiStatus, + subject, + description, }; } diff --git a/src/utils/workItems.ts b/src/utils/workItems.ts index 6fea01be..533125bc 100644 --- a/src/utils/workItems.ts +++ b/src/utils/workItems.ts @@ -175,10 +175,13 @@ function mapRawItemToWorkItem( }; if (ctx) { - let targetStageId = resolveTargetStageId(mapped.PipelineStageId, ctx.stages); - if (!targetStageId) { - targetStageId = ctx.firstStageId; - } + // A work item's changes are promoted into the branch of its *next* pipeline stage. Only fall + // back to the first stage when the work item hasn't entered the pipeline yet (no current stage); + // a work item already on the final stage has no next stage, so it has no target branch (e.g. a + // closed work item that reached the last stage), and the branch is left blank. + const targetStageId = mapped.PipelineStageId + ? resolveTargetStageId(mapped.PipelineStageId, ctx.stages) + : ctx.firstStageId; const targetStage = findStageById(ctx.stages, targetStageId); mapped.TargetBranch = getBranchNameFromStage(targetStage); mapped.TargetStageId = targetStageId; diff --git a/test/commands/devops/pipeline/create.test.ts b/test/commands/devops/pipeline/create.test.ts index db6158de..22580939 100644 --- a/test/commands/devops/pipeline/create.test.ts +++ b/test/commands/devops/pipeline/create.test.ts @@ -72,6 +72,10 @@ describe('devops pipeline create', () => { repoType: 'github', created: false, }, + stages: [ + { id: '0Sx000000000001', name: 'Integration' }, + { id: '0Sx000000000002', name: 'UAT' }, + ], }); await CreateCommand.run([ @@ -86,6 +90,9 @@ describe('devops pipeline create', () => { 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('Stages:'); + expect(ctx.stdout).to.contain('Integration (0Sx000000000001)'); + expect(ctx.stdout).to.contain('UAT (0Sx000000000002)'); 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 project add'); diff --git a/test/commands/devops/promote.test.ts b/test/commands/devops/promote.test.ts index 6c7e1517..02c6d3b4 100644 --- a/test/commands/devops/promote.test.ts +++ b/test/commands/devops/promote.test.ts @@ -42,6 +42,7 @@ describe('devops promote', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let PromoteCommand: any; const promoteStageStub = sinon.stub(); + const findInFlightPromotionsStub = sinon.stub(); const resolveProjectIdFromWorkItemStub = sinon.stub(); const getPipelineIdForProjectStub = sinon.stub(); const validatePromotionStub = sinon.stub(); @@ -50,6 +51,7 @@ describe('devops promote', () => { const mod = await esmock('../../../src/commands/devops/promote.js', { '../../../src/utils/promoteStage.js': { promoteStage: promoteStageStub, + findInFlightPromotions: findInFlightPromotionsStub, }, '../../../src/utils/prepareWorkItem.js': { resolveProjectIdFromWorkItem: resolveProjectIdFromWorkItemStub, @@ -67,6 +69,9 @@ describe('devops promote', () => { beforeEach(() => { sandbox = sinon.createSandbox(); promoteStageStub.reset(); + findInFlightPromotionsStub.reset(); + // Default: no promotion in flight, so the guard is a no-op + findInFlightPromotionsStub.resolves([]); resolveProjectIdFromWorkItemStub.reset(); getPipelineIdForProjectStub.reset(); validatePromotionStub.reset(); @@ -502,6 +507,67 @@ describe('devops promote', () => { // ── Validation gate ─────────────────────────────────────────────────────── + describe('in-flight promotion guard', () => { + test + .stdout() + .stderr() + .it('blocks promotion when another promotion is already in flight', async (ctx) => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); + findInFlightPromotionsStub.resolves([ + { id: '0Rq000000000001', requestToken: 'TOKEN-123', status: 'IN_PROGRESS' }, + ]); + + try { + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already in progress'); + expect(ctx.stderr).to.contain('TOKEN-123'); + // Neither validation nor the promote submission should run when blocked + expect(validatePromotionStub.called).to.be.false; + expect(promoteStageStub.called).to.be.false; + }); + + test + .stdout() + .stderr() + .it('promotes anyway when --force is passed despite an in-flight promotion', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); + findInFlightPromotionsStub.resolves([ + { id: '0Rq000000000001', requestToken: 'TOKEN-123', status: 'IN_PROGRESS' }, + ]); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003', '--force']); + + // --force bypasses the guard entirely + expect(findInFlightPromotionsStub.called).to.be.false; + expect(promoteStageStub.calledOnce).to.be.true; + }); + + test + .stdout() + .stderr() + .it('proceeds when the in-flight check itself fails (best-effort guard)', async () => { + resolveProjectIdFromWorkItemStub.resolves({ projectId: 'PROJ001', pipelineStageId: '' }); + getPipelineIdForProjectStub.resolves('PIPE001'); + queryMock = sinon.stub().resolves({ records: [{ Id: '1fkxx0000000001', Status: 'READY_TO_PROMOTE' }] }); + findInFlightPromotionsStub.rejects(new Error('query failed')); + promoteStageStub.resolves(mockPromoteResult); + + await PromoteCommand.run(['-o', 'testOrg', '-i', '1fkxx0000000001', '-t', '1QVxx0000000003']); + + expect(promoteStageStub.calledOnce).to.be.true; + }); + }); + describe('validation gate', () => { test .stdout() diff --git a/test/commands/devops/stage/branch/add.test.ts b/test/commands/devops/stage/branch/add.test.ts index 58ef915a..fb538a38 100644 --- a/test/commands/devops/stage/branch/add.test.ts +++ b/test/commands/devops/stage/branch/add.test.ts @@ -26,12 +26,16 @@ describe('devops stage branch add', () => { const mockConnection = { getApiVersion: () => '65.0' }; const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; const addStageBranchStub = sinon.stub(); + const getStageBranchStub = sinon.stub(); + const deleteOrphanedBranchStub = sinon.stub(); const fetchPipelineStagesStub = sinon.stub(); before(async () => { const mod = await esmock('../../../../../src/commands/devops/stage/branch/add.js', { '../../../../../src/utils/addStageBranch.js': { addStageBranch: addStageBranchStub, + getStageBranch: getStageBranchStub, + deleteOrphanedBranch: deleteOrphanedBranchStub, }, '../../../../../src/utils/pipelineUtils.js': { fetchPipelineStages: fetchPipelineStagesStub, @@ -43,6 +47,10 @@ describe('devops stage branch add', () => { beforeEach(() => { sandbox = sinon.createSandbox(); addStageBranchStub.reset(); + getStageBranchStub.reset(); + getStageBranchStub.resolves(undefined); + deleteOrphanedBranchStub.reset(); + deleteOrphanedBranchStub.resolves(true); fetchPipelineStagesStub.reset(); }); @@ -346,6 +354,118 @@ describe('devops stage branch add', () => { }); }); + describe('existing branch guard', () => { + test + .stdout() + .stderr() + .it('blocks when the stage already has a branch and --force is not set', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageBranchStub.resolves({ branchId: '0Xq000000000001', branchName: '$$UAT' }); + + try { + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'UAT123', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already has a branch'); + // The "$$" marker prefix is stripped for display. + expect(ctx.stderr).to.contain('UAT'); + expect(ctx.stderr).to.not.contain('$$'); + // No new branch is associated while one already exists. + expect(addStageBranchStub.called).to.equal(false); + expect(deleteOrphanedBranchStub.called).to.equal(false); + }); + + test + .stdout() + .stderr() + .it('with --force, associates the new branch then removes the orphaned one', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageBranchStub.resolves({ branchId: '0Xq000000000001', branchName: '$$UAT' }); + deleteOrphanedBranchStub.resolves(true); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000001', + branchName: 'UAT123', + branchCreated: true, + repoBranchId: '0Xq000000000002', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'UAT123', + '--create-vcs-branch', + '--force', + ]); + + // The new branch is associated first, then the previous branch record is removed. + expect(addStageBranchStub.calledOnce).to.equal(true); + expect(deleteOrphanedBranchStub.calledOnce).to.equal(true); + expect(deleteOrphanedBranchStub.firstCall.args[1]).to.equal('0Xq000000000001'); + expect(addStageBranchStub.calledBefore(deleteOrphanedBranchStub)).to.equal(true); + expect(ctx.stdout).to.contain('Created branch and associated it with the stage.'); + expect(ctx.stdout).to.contain("Removed the stage's previous branch"); + }); + + test + .stdout() + .stderr() + .it('with --force, warns but succeeds when the orphaned branch cleanup fails', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageBranchStub.resolves({ branchId: '0Xq000000000001', branchName: '$$UAT' }); + deleteOrphanedBranchStub.rejects(new Error('delete not allowed')); + addStageBranchStub.resolves({ + success: true, + stageId: '0Xp000000000001', + branchName: 'UAT123', + branchCreated: true, + repoBranchId: '0Xq000000000002', + pipelineId: '0Xo000000000001', + }); + + await AddBranchCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--branch-name', + 'UAT123', + '--create-vcs-branch', + '--force', + ]); + + // The command still reports success even though cleanup failed. + expect(ctx.stdout).to.contain('Created branch and associated it with the stage.'); + expect(ctx.stderr).to.contain("couldn't remove the previous branch record"); + }); + }); + describe('rethrows other errors', () => { test .stdout() diff --git a/test/commands/devops/stage/environment/add.test.ts b/test/commands/devops/stage/environment/add.test.ts index 802a20a0..a307bcdf 100644 --- a/test/commands/devops/stage/environment/add.test.ts +++ b/test/commands/devops/stage/environment/add.test.ts @@ -27,6 +27,8 @@ describe('devops stage environment add', () => { const mockConnection = { getApiVersion: () => '65.0', query: queryStub }; const mockOrg = { id: '1', getOrgId: () => '1', getConnection: () => mockConnection, getUsername: () => 'testOrg' }; const addStageEnvironmentStub = sinon.stub(); + const getStageEnvironmentStub = sinon.stub(); + const deleteOrphanedEnvironmentStub = sinon.stub(); const fetchPipelineStagesStub = sinon.stub(); const execFileStub = sinon.stub(); @@ -34,6 +36,8 @@ describe('devops stage environment add', () => { const mod = await esmock('../../../../../src/commands/devops/stage/environment/add.js', { '../../../../../src/utils/addStageEnvironment.js': { addStageEnvironment: addStageEnvironmentStub, + getStageEnvironment: getStageEnvironmentStub, + deleteOrphanedEnvironment: deleteOrphanedEnvironmentStub, }, '../../../../../src/utils/pipelineUtils.js': { fetchPipelineStages: fetchPipelineStagesStub, @@ -48,6 +52,10 @@ describe('devops stage environment add', () => { beforeEach(() => { sandbox = sinon.createSandbox(); addStageEnvironmentStub.reset(); + getStageEnvironmentStub.reset(); + getStageEnvironmentStub.resolves(undefined); + deleteOrphanedEnvironmentStub.reset(); + deleteOrphanedEnvironmentStub.resolves(true); fetchPipelineStagesStub.reset(); execFileStub.reset(); queryStub.reset(); @@ -458,4 +466,154 @@ describe('devops stage environment add', () => { expect(opened).to.not.match(/["\s]/); }); }); + + describe('existing environment guard', () => { + test + .stdout() + .stderr() + .it('blocks when the stage already has an environment and --force is not set', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageEnvironmentStub.resolves({ environmentId: '0Hi000000000001', environmentName: 'Existing_Org' }); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + expect(ctx.stderr).to.contain('already has an environment'); + expect(ctx.stderr).to.contain('Existing_Org'); + // The new environment must not be created while one already exists. + expect(addStageEnvironmentStub.called).to.equal(false); + expect(deleteOrphanedEnvironmentStub.called).to.equal(false); + }); + + test + .stdout() + .stderr() + .it('with --force, adds the new environment first then removes the previous one', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageEnvironmentStub.resolves({ environmentId: '0Hi000000000001', environmentName: 'Existing_Org' }); + deleteOrphanedEnvironmentStub.resolves(true); + addStageEnvironmentStub.resolves({ + success: true, + stageId: '0Xp000000000001', + environmentId: '0Hi000000000002', + environmentName: 'Production_Org', + orgType: 'Production', + pipelineId: '0Xo000000000001', + organizationId: '00D000000000002', + }); + + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + '--force', + ]); + + // The new environment is associated first; the old one is removed only afterward. + expect(addStageEnvironmentStub.calledOnce).to.equal(true); + expect(deleteOrphanedEnvironmentStub.calledOnce).to.equal(true); + expect(deleteOrphanedEnvironmentStub.firstCall.args[1]).to.equal('0Hi000000000001'); + expect(addStageEnvironmentStub.calledBefore(deleteOrphanedEnvironmentStub)).to.equal(true); + expect(ctx.stdout).to.contain('Successfully added environment to the stage.'); + expect(ctx.stdout).to.contain('0Hi000000000002'); + expect(ctx.stdout).to.contain("Removed the stage's previous environment"); + }); + + test + .stdout() + .stderr() + .it('with --force, keeps the existing environment when the add fails', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageEnvironmentStub.resolves({ environmentId: '0Hi000000000001', environmentName: 'Existing_Org' }); + addStageEnvironmentStub.rejects(new Error('Authentication timed out')); + + try { + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + '--force', + ]); + expect.fail('should have thrown'); + } catch (e) { + // expected + } + + // The add failed, so the existing environment must be left intact — no delete runs. + expect(deleteOrphanedEnvironmentStub.called).to.equal(false); + }); + + test + .stdout() + .stderr() + .it('with --force, warns but still succeeds when removing the previous environment fails', async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(Org, 'create' as any).returns(mockOrg); + fetchPipelineStagesStub.resolves([{ Id: '0Xp000000000001', Name: 'Production' }]); + getStageEnvironmentStub.resolves({ environmentId: '0Hi000000000001', environmentName: 'Existing_Org' }); + deleteOrphanedEnvironmentStub.rejects(new Error('delete failed')); + addStageEnvironmentStub.resolves({ + success: true, + stageId: '0Xp000000000001', + environmentId: '0Hi000000000002', + environmentName: 'Production_Org', + orgType: 'Production', + pipelineId: '0Xo000000000001', + organizationId: '00D000000000002', + }); + + await AddEnvironmentCommand.run([ + '--target-org', + 'testOrg', + '--pipeline-id', + '0Xo000000000001', + '--stage-id', + '0Xp000000000001', + '--environment-name', + 'Production_Org', + '--org-type', + 'Production', + '--force', + ]); + + // Cleanup failure is a warning, not a command failure — the new environment is already set. + expect(ctx.stdout).to.contain('Successfully added environment to the stage.'); + expect(ctx.stderr).to.contain("couldn't remove the previous environment record"); + }); + }); }); diff --git a/test/commands/devops/work-item/create.test.ts b/test/commands/devops/work-item/create.test.ts index 460f50fd..0acdfe68 100644 --- a/test/commands/devops/work-item/create.test.ts +++ b/test/commands/devops/work-item/create.test.ts @@ -62,6 +62,9 @@ describe('devops work-item create', () => { await CreateCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001', '--subject', 'Fix bug']); expect(ctx.stdout).to.contain('Successfully created work item'); + expect(ctx.stdout).to.contain('ID: WI001'); + expect(ctx.stdout).to.contain('Name: WI-001'); + expect(ctx.stdout).to.contain('Subject: Fix bug'); }); }); diff --git a/test/commands/devops/work-item/list.test.ts b/test/commands/devops/work-item/list.test.ts index a3990cab..82ad247c 100644 --- a/test/commands/devops/work-item/list.test.ts +++ b/test/commands/devops/work-item/list.test.ts @@ -69,6 +69,8 @@ describe('devops work-item list', () => { await ListCommand.run(['--target-org', 'testOrg', '--project-id', '1Qg000000000001']); expect(ctx.stdout).to.contain('WI-001'); + // The work item ID must be shown alongside the name. + expect(ctx.stdout).to.contain('WI001'); }); }); diff --git a/test/utils/addStageBranch.test.ts b/test/utils/addStageBranch.test.ts index 09ed4c12..9c9eb310 100644 --- a/test/utils/addStageBranch.test.ts +++ b/test/utils/addStageBranch.test.ts @@ -17,14 +17,17 @@ import { expect } from '@oclif/test'; import sinon from 'sinon'; import { Connection } from '@salesforce/core'; -import { addStageBranch } from '../../src/utils/addStageBranch.js'; +import { addStageBranch, getStageBranch, deleteOrphanedBranch } from '../../src/utils/addStageBranch.js'; describe('addStageBranch utilities', () => { let connectionStub: sinon.SinonStubbedInstance; + let queryStub: sinon.SinonStub; beforeEach(() => { connectionStub = sinon.createStubInstance(Connection); (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + queryStub = sinon.stub(); + connectionStub.query = queryStub as unknown as typeof connectionStub.query; }); afterEach(() => { @@ -125,4 +128,55 @@ describe('addStageBranch utilities', () => { expect((e as Error).message).to.contain('Network timeout'); } }); + + describe('getStageBranch', () => { + it('returns the branch when the stage references one', async () => { + queryStub.resolves({ + records: [{ SourceCodeRepositoryBranchId: '0Xq000000000001', SourceCodeRepositoryBranch: { Name: '$$UAT' } }], + }); + + const result = await getStageBranch(connectionStub as unknown as Connection, '0Xp000000000001'); + + expect(result).to.deep.equal({ branchId: '0Xq000000000001', branchName: '$$UAT' }); + expect(queryStub.firstCall.args[0]).to.contain("WHERE Id = '0Xp000000000001'"); + }); + + it('returns undefined when the stage has no branch', async () => { + queryStub.resolves({ records: [{ SourceCodeRepositoryBranchId: null, SourceCodeRepositoryBranch: null }] }); + + const result = await getStageBranch(connectionStub as unknown as Connection, '0Xp000000000001'); + expect(result).to.be.undefined; + }); + + it('returns undefined when the stage is not found', async () => { + queryStub.resolves({ records: [] }); + + const result = await getStageBranch(connectionStub as unknown as Connection, '0Xp000000000001'); + expect(result).to.be.undefined; + }); + }); + + describe('deleteOrphanedBranch', () => { + it('deletes the branch and returns true when no stage references it', async () => { + queryStub.resolves({ records: [] }); + const deleteStub = sinon.stub().resolves({ success: true }); + connectionStub.sobject = sinon.stub().returns({ delete: deleteStub }) as unknown as typeof connectionStub.sobject; + + const deleted = await deleteOrphanedBranch(connectionStub as unknown as Connection, '0Xq000000000001'); + + expect(deleted).to.equal(true); + expect(deleteStub.calledOnceWith('0Xq000000000001')).to.equal(true); + }); + + it('leaves the branch intact and returns false when a stage still references it', async () => { + queryStub.resolves({ records: [{ Id: '0Xp000000000002' }] }); + const deleteStub = sinon.stub().resolves({ success: true }); + connectionStub.sobject = sinon.stub().returns({ delete: deleteStub }) as unknown as typeof connectionStub.sobject; + + const deleted = await deleteOrphanedBranch(connectionStub as unknown as Connection, '0Xq000000000001'); + + expect(deleted).to.equal(false); + expect(deleteStub.called).to.equal(false); + }); + }); }); diff --git a/test/utils/addStageEnvironment.test.ts b/test/utils/addStageEnvironment.test.ts index bb24ca64..b9bd5240 100644 --- a/test/utils/addStageEnvironment.test.ts +++ b/test/utils/addStageEnvironment.test.ts @@ -21,6 +21,7 @@ import { addStageEnvironment, createEnvironment, getEnvironment, + getStageEnvironment, validateEnvironment, pollForAuthentication, } from '../../src/utils/addStageEnvironment.js'; @@ -28,12 +29,15 @@ import { describe('addStageEnvironment utilities', () => { let connectionStub: sinon.SinonStubbedInstance; let requestStub: sinon.SinonStub; + let queryStub: sinon.SinonStub; beforeEach(() => { connectionStub = sinon.createStubInstance(Connection); (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); requestStub = sinon.stub(); connectionStub.request = requestStub as unknown as typeof connectionStub.request; + queryStub = sinon.stub(); + connectionStub.query = queryStub as unknown as typeof connectionStub.query; }); afterEach(() => { @@ -113,6 +117,40 @@ describe('addStageEnvironment utilities', () => { }); }); + describe('getStageEnvironment', () => { + it('returns the environment when the stage references one', async () => { + queryStub.resolves({ + records: [{ DevOpsEnvironmentId: '0Hi000000000001', DevOpsEnvironment: { Name: 'Production_Org' } }], + }); + + const result = await getStageEnvironment(connectionStub as unknown as Connection, '0Xp000000000001'); + + expect(result).to.deep.equal({ environmentId: '0Hi000000000001', environmentName: 'Production_Org' }); + expect(queryStub.firstCall.args[0]).to.contain("WHERE Id = '0Xp000000000001'"); + }); + + it('returns undefined when the stage has no environment', async () => { + queryStub.resolves({ records: [{ DevOpsEnvironmentId: null, DevOpsEnvironment: null }] }); + + const result = await getStageEnvironment(connectionStub as unknown as Connection, '0Xp000000000001'); + expect(result).to.be.undefined; + }); + + it('returns undefined when the stage is not found', async () => { + queryStub.resolves({ records: [] }); + + const result = await getStageEnvironment(connectionStub as unknown as Connection, '0Xp000000000001'); + expect(result).to.be.undefined; + }); + + it('returns the environment id when the related name is missing', async () => { + queryStub.resolves({ records: [{ DevOpsEnvironmentId: '0Hi000000000001', DevOpsEnvironment: null }] }); + + const result = await getStageEnvironment(connectionStub as unknown as Connection, '0Xp000000000001'); + expect(result).to.deep.equal({ environmentId: '0Hi000000000001', environmentName: undefined }); + }); + }); + describe('getEnvironment', () => { it('calls GET /connect/devops/environment/{id}', async () => { requestStub.resolves({ diff --git a/test/utils/createPipeline.test.ts b/test/utils/createPipeline.test.ts index 1ad646cb..74b8c09b 100644 --- a/test/utils/createPipeline.test.ts +++ b/test/utils/createPipeline.test.ts @@ -319,6 +319,58 @@ describe('createPipeline utilities', () => { expect(body).to.not.have.property('projectIds'); }); + it('fetches and returns stage ids ordered along the promotion path', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '1PJ000000000001', + message: '', + status: 'SUCCESS', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + // Returned out of order to verify NextStageId-based ordering + (connectionStub.query as sinon.SinonStub).resolves({ + records: [ + { Id: '0Sx000000000003', Name: 'Production', NextStageId: null }, + { Id: '0Sx000000000001', Name: 'Integration', NextStageId: '0Sx000000000002' }, + { Id: '0Sx000000000002', Name: 'UAT', NextStageId: '0Sx000000000003' }, + ], + }); + + const result = await createPipeline({ + connection: connectionStub as unknown as Connection, + name: 'Release Pipeline', + repo: 'https://github.com/myorg/myrepo', + repoType: 'github', + }); + + expect(result.pipelineId).to.equal('1PJ000000000001'); + expect(result.stages).to.deep.equal([ + { id: '0Sx000000000001', name: 'Integration' }, + { id: '0Sx000000000002', name: 'UAT' }, + { id: '0Sx000000000003', name: 'Production' }, + ]); + }); + + it('succeeds without stages when the stage lookup fails', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + id: '1PJ000000000009', + message: '', + status: 'SUCCESS', + }); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + (connectionStub.query as sinon.SinonStub).rejects(new Error('QUERY failed')); + + 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('1PJ000000000009'); + expect(result.stages).to.be.undefined; + }); + it('propagates API errors', async () => { (connectionStub.request as sinon.SinonStub).rejects(new Error('Bad Request')); (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); diff --git a/test/utils/createWorkItem.test.ts b/test/utils/createWorkItem.test.ts index 2afec9ba..a2a31f52 100644 --- a/test/utils/createWorkItem.test.ts +++ b/test/utils/createWorkItem.test.ts @@ -46,6 +46,27 @@ describe('createWorkItem', () => { expect(result.subject).to.equal('Fix bug'); }); + it('returns success with workItemId and workItemName from response', async () => { + (connectionStub.request as sinon.SinonStub).resolves({ + success: 'true', + workItemId: '1fkxx000000003F', + workItemName: 'WI-000003', + }); + + const result = await createWorkItem({ + connection: connectionStub as unknown as Connection, + projectId: 'PROJ001', + subject: 'new work item', + description: '', + }); + + expect(result.success).to.be.true; + expect(result.workItemId).to.equal('1fkxx000000003F'); + expect(result.workItemName).to.equal('WI-000003'); + // subject is not in the response, so it falls back to the requested subject + expect(result.subject).to.equal('new work item'); + }); + it('returns success with uppercase Id and Name from response', async () => { (connectionStub.request as sinon.SinonStub).resolves({ Id: 'WI002', Name: 'WI-002', Subject: 'My subject' }); diff --git a/test/utils/promoteStage.test.ts b/test/utils/promoteStage.test.ts index 265f6166..6028c6bb 100644 --- a/test/utils/promoteStage.test.ts +++ b/test/utils/promoteStage.test.ts @@ -17,7 +17,7 @@ import { expect } from '@oclif/test'; import sinon from 'sinon'; import { Connection } from '@salesforce/core'; -import { promoteStage } from '../../src/utils/promoteStage.js'; +import { promoteStage, findInFlightPromotions } from '../../src/utils/promoteStage.js'; describe('promoteStage utilities', () => { let connectionStub: sinon.SinonStubbedInstance; @@ -149,4 +149,49 @@ describe('promoteStage utilities', () => { expect(result.message).to.equal(''); expect(result.promotedWorkitemIds).to.deep.equal([]); }); + + describe('findInFlightPromotions', () => { + it('queries stage-scoped in-flight promotions and maps the records', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ + records: [ + { + Id: '0Pr000000000001', + Status: 'IN_PROGRESS', + RequestInfoId: '8Mw001', + RequestInfo: { RequestToken: 'TOKEN-1' }, + }, + { Id: '0Pr000000000002', Status: 'NEW', RequestInfoId: '8Mw002', RequestInfo: { RequestToken: 'TOKEN-2' } }, + ], + }); + + const result = await findInFlightPromotions(connectionStub as unknown as Connection, '1QVxx0000000003'); + + const soql = (connectionStub.query as sinon.SinonStub).firstCall.args[0] as string; + expect(soql).to.contain('FROM DevopsPipelnStgProm'); + expect(soql).to.contain("PipelineStageId = '1QVxx0000000003'"); + expect(soql).to.contain("'NEW', 'IN_PROGRESS', 'FINALIZING'"); + expect(result).to.deep.equal([ + { id: '0Pr000000000001', requestToken: 'TOKEN-1', status: 'IN_PROGRESS' }, + { id: '0Pr000000000002', requestToken: 'TOKEN-2', status: 'NEW' }, + ]); + }); + + it('falls back to the request info id when the token is not populated', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ + records: [{ Id: '0Pr000000000003', Status: 'FINALIZING', RequestInfoId: '8Mw003', RequestInfo: null }], + }); + + const result = await findInFlightPromotions(connectionStub as unknown as Connection, '1QVxx0000000003'); + + expect(result).to.deep.equal([{ id: '0Pr000000000003', requestToken: '8Mw003', status: 'FINALIZING' }]); + }); + + it('returns an empty array when no promotions are in flight', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [] }); + + const result = await findInFlightPromotions(connectionStub as unknown as Connection, '1QVxx0000000003'); + + expect(result).to.deep.equal([]); + }); + }); }); diff --git a/test/utils/updateWorkItem.test.ts b/test/utils/updateWorkItem.test.ts new file mode 100644 index 00000000..b516d537 --- /dev/null +++ b/test/utils/updateWorkItem.test.ts @@ -0,0 +1,218 @@ +/* + * 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 { assertStatusTransitionAllowed, updateWorkItem } from '../../src/utils/updateWorkItem.js'; + +describe('updateWorkItem status transition guard', () => { + let connectionStub: sinon.SinonStubbedInstance; + const workItemId = '0Wx000000000001'; + const projectId = '1Qg000000000001'; + + beforeEach(() => { + connectionStub = sinon.createStubInstance(Connection); + (connectionStub.getApiVersion as sinon.SinonStub).returns('65.0'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('assertStatusTransitionAllowed', () => { + it('allows IN_PROGRESS when the work item is NEW', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'NEW' }] }); + + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'IN_PROGRESS'); + + const soql = (connectionStub.query as sinon.SinonStub).firstCall.args[0] as string; + expect(soql).to.contain('SELECT Status FROM WorkItem'); + expect(soql).to.contain(`Id = '${workItemId}'`); + }); + + it('blocks IN_PROGRESS when the work item is not NEW', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'IN_PROGRESS' }] }); + + try { + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'IN_PROGRESS'); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('currently IN_PROGRESS'); + expect((e as Error).message).to.contain('only allowed from NEW'); + } + }); + + it('allows READY_TO_PROMOTE when the work item is IN_REVIEW', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'IN_REVIEW' }] }); + + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'READY_TO_PROMOTE'); + }); + + it('blocks READY_TO_PROMOTE when the work item is not IN_REVIEW', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'NEW' }] }); + + try { + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'READY_TO_PROMOTE'); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('currently NEW'); + expect((e as Error).message).to.contain('only allowed from IN_REVIEW'); + } + }); + + it('does not query for a target status with no precondition', async () => { + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'CLOSED'); + + expect((connectionStub.query as sinon.SinonStub).called).to.be.false; + }); + + it('throws when the work item is not found', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [] }); + + try { + await assertStatusTransitionAllowed(connectionStub as unknown as Connection, workItemId, 'IN_PROGRESS'); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('not found'); + } + }); + }); + + describe('updateWorkItem', () => { + it('blocks the PATCH when the transition is invalid', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'IN_PROGRESS' }] }); + + try { + await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + status: 'In Progress', + }); + expect.fail('should have thrown'); + } catch (e: unknown) { + expect((e as Error).message).to.contain('Cannot change status'); + } + + expect((connectionStub.request as sinon.SinonStub).called).to.be.false; + }); + + it('sends the PATCH when the transition is valid', async () => { + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'NEW' }] }); + (connectionStub.request as sinon.SinonStub).resolves({ status: 'IN_PROGRESS' }); + + const result = await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + status: 'In Progress', + }); + + expect(result.success).to.be.true; + const callArgs = (connectionStub.request as sinon.SinonStub).firstCall.args[0]; + expect(callArgs.method).to.equal('PATCH'); + const body = JSON.parse(callArgs.body as string) as Record; + expect(body.status).to.equal('IN_PROGRESS'); + }); + + it('updates subject/description via the sObject API, not the connect endpoint', async () => { + const updateStub = sinon.stub().resolves({ id: workItemId, success: true, errors: [] }); + (connectionStub.sobject as sinon.SinonStub).returns({ update: updateStub }); + + const result = await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + subject: 'New subject', + description: 'New description', + }); + + expect((connectionStub.sobject as sinon.SinonStub).calledWith('WorkItem')).to.be.true; + expect(updateStub.firstCall.args[0]).to.deep.equal({ + Id: workItemId, + Subject: 'New subject', + Description: 'New description', + }); + // No status change → no status query and no connect PATCH. + expect((connectionStub.query as sinon.SinonStub).called).to.be.false; + expect((connectionStub.request as sinon.SinonStub).called).to.be.false; + expect(result).to.deep.include({ success: true, subject: 'New subject', description: 'New description' }); + }); + + it('returns a failure result when the sObject update fails', async () => { + const updateStub = sinon.stub().resolves({ id: workItemId, success: false, errors: [{ message: 'boom' }] }); + (connectionStub.sobject as sinon.SinonStub).returns({ update: updateStub }); + + const result = await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + description: 'New description', + }); + + expect(result.success).to.be.false; + expect(result.error).to.contain('boom'); + }); + + it('updates both fields and status in a single call', async () => { + const updateStub = sinon.stub().resolves({ id: workItemId, success: true, errors: [] }); + (connectionStub.sobject as sinon.SinonStub).returns({ update: updateStub }); + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'NEW' }] }); + (connectionStub.request as sinon.SinonStub).resolves({ success: 'true' }); + + const result = await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + subject: 'New subject', + status: 'In Progress', + }); + + expect(updateStub.calledOnce).to.be.true; + expect((connectionStub.request as sinon.SinonStub).calledOnce).to.be.true; + expect(result.status).to.equal('IN_PROGRESS'); + expect(result.subject).to.equal('New subject'); + }); + + it('returns a partial-success result naming saved fields when the status PATCH fails', async () => { + const updateStub = sinon.stub().resolves({ id: workItemId, success: true, errors: [] }); + (connectionStub.sobject as sinon.SinonStub).returns({ update: updateStub }); + (connectionStub.query as sinon.SinonStub).resolves({ records: [{ Status: 'NEW' }] }); + (connectionStub.request as sinon.SinonStub).rejects(new Error('network error')); + + const result = await updateWorkItem({ + connection: connectionStub as unknown as Connection, + workItemId, + projectId, + subject: 'New subject', + description: 'New description', + status: 'In Progress', + }); + + // The sObject write committed before the status PATCH failed. + expect(updateStub.calledOnce).to.be.true; + expect(result.success).to.be.false; + // Status was not applied, so it is not reported as changed. + expect(result.status).to.be.undefined; + // The subject/description that did persist are reported, and the error names them. + expect(result.subject).to.equal('New subject'); + expect(result.description).to.equal('New description'); + expect(result.error).to.contain('network error'); + expect(result.error).to.contain('subject and description'); + }); + }); +}); diff --git a/test/utils/workItems.test.ts b/test/utils/workItems.test.ts index b31118a4..78695e96 100644 --- a/test/utils/workItems.test.ts +++ b/test/utils/workItems.test.ts @@ -176,4 +176,52 @@ describe('fetchWorkItems', () => { expect(result[0].TargetStageId).to.equal('S2'); expect(result[0].TargetBranch).to.equal('branch-2'); }); + + it('leaves the target branch blank for a work item on the final stage', async () => { + // Work item currently sits on S2, the last stage (NextStageId is null), so it has no next + // stage to promote into — the target branch must not fall back to the first stage. + (connectionStub.query as sinon.SinonStub).resolves({ + records: [{ ...MOCK_RECORD, DevopsPipelineStageId: 'S2' }], + }); + (connectionStub.request as sinon.SinonStub).rejects(new Error('no vcs')); + + const { fetchWorkItems } = await esmock('../../src/utils/workItems.js', { + '../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: sinon.stub().resolves('PIPE001'), + fetchPipelineStages: sinon.stub().resolves([ + { Id: 'S1', NextStageId: 'S2', SourceCodeRepositoryBranch: { Name: 'branch-1' } }, + { Id: 'S2', NextStageId: null, SourceCodeRepositoryBranch: { Name: 'branch-2' } }, + ]), + }, + }); + + const result = await fetchWorkItems(connectionStub as unknown as Connection, 'PROJ001'); + + expect(result[0].TargetStageId).to.be.undefined; + expect(result[0].TargetBranch).to.be.undefined; + }); + + it('targets the first stage for a work item that has not entered the pipeline', async () => { + // No current stage (DevopsPipelineStageId null) — the work item first promotes into the first + // stage (S1, the stage nothing points to), so the target branch is that stage's branch. + (connectionStub.query as sinon.SinonStub).resolves({ + records: [{ ...MOCK_RECORD, DevopsPipelineStageId: null }], + }); + (connectionStub.request as sinon.SinonStub).rejects(new Error('no vcs')); + + const { fetchWorkItems } = await esmock('../../src/utils/workItems.js', { + '../../src/utils/pipelineUtils.js': { + getPipelineIdForProject: sinon.stub().resolves('PIPE001'), + fetchPipelineStages: sinon.stub().resolves([ + { Id: 'S1', NextStageId: 'S2', SourceCodeRepositoryBranch: { Name: 'branch-1' } }, + { Id: 'S2', NextStageId: null, SourceCodeRepositoryBranch: { Name: 'branch-2' } }, + ]), + }, + }); + + const result = await fetchWorkItems(connectionStub as unknown as Connection, 'PROJ001'); + + expect(result[0].TargetStageId).to.equal('S1'); + expect(result[0].TargetBranch).to.equal('branch-1'); + }); });