diff --git a/docs/command-graph.html b/docs/command-graph.html index 2245c415..582ae7a8 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -314,6 +314,36 @@ description: "List packages in the target team. Lists staging packages by default.", options: ["-p, --profile ", "--json", "--flavors ", "-h, --help"] }, + // config branch + { id: "config_branch_area", label: "branch (beta)", group: "subarea", path: "config branch", + description: "Author, merge, and mirror package branches", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_create", label: "create (beta)", group: "command", path: "config branch create", + description: "Create a new branch from a source version", + options: ["-p, --profile ", "--packageKey (Main package key to branch from)", "--branchKey ", "--sourceVersion ", "--validate (default: false)", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_list", label: "list (beta)", group: "command", path: "config branch list", + description: "List branches of a main package", + options: ["-p, --profile ", "--packageKey ", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_delete", label: "delete (beta)", group: "command", path: "config branch delete", + description: "Delete a branch", + options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey ", "-h, --help"] }, + + // config branch settings + { id: "config_branch_settings_area", label: "settings (beta)", group: "subsubarea", path: "config branch settings", + description: "Commands to manage branch settings on a package", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_settings_set", label: "set (beta)", group: "command", path: "config branch settings set", + description: "Configure branch settings on a main package", + options: ["-p, --profile ", "--packageKey ", "--enabled (true|false)", "--json (default: false)", "-h, --help"] }, + + // config branch merge + { id: "config_branch_merge_area", label: "merge (beta)", group: "subsubarea", path: "config branch merge", + description: "Commands to preview and apply branch merges", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_merge_preview", label: "preview (beta)", group: "command", path: "config branch merge preview", + description: "Preview the changes a merge would apply to a target branch", + options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "--sourceKey ", "--sourceVersion (version or \"LATEST\")", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_merge_apply", label: "apply (beta)", group: "command", path: "config branch merge apply", + description: "Merge changes from a source into a target branch", + options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "-f, --file (MergeBranchTransport payload; required only when the merge has conflicts)", "--sourceKey (overrides the file's 'sourceKey')", "--sourceVersion (overrides the file's 'sourceVersion'; or \"LATEST\")", "--bump (PATCH | MINOR | MAJOR)", "--newVersion (pin an explicit semver)", "--summary ", "--json (default: false)", "-h, --help"] }, + // config metadata { id: "config_metadata_area", label: "metadata", group: "subarea", path: "config metadata", description: "Commands related to package metadata", options: ["-p, --profile ", "-h, --help"] }, @@ -471,6 +501,12 @@ ["area_config","config_package_area"], ["config_package_area","config_package_import"],["config_package_area","config_package_export"], ["config_package_area","config_package_validate"],["config_package_area","config_package_list"], + ["area_config","config_branch_area"], + ["config_branch_area","config_branch_create"],["config_branch_area","config_branch_list"], + ["config_branch_area","config_branch_delete"], + ["config_branch_area","config_branch_settings_area"],["config_branch_settings_area","config_branch_settings_set"], + ["config_branch_area","config_branch_merge_area"], + ["config_branch_merge_area","config_branch_merge_preview"],["config_branch_merge_area","config_branch_merge_apply"], ["area_config","config_metadata_area"],["config_metadata_area","config_metadata_export"], ["area_config","config_versions_area"],["config_versions_area","config_versions_get"], ["config_versions_area","config_versions_create"], diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md new file mode 100644 index 00000000..f7675a32 --- /dev/null +++ b/docs/user-guide/branch-commands.md @@ -0,0 +1,139 @@ +# Branch Commands (beta) + +The `config branch` command group lets you author and merge branches from the CLI. + +## Concepts + +- **Main package** — a regular package, identified by its plain `` (no `@`). +- **Branch** — a separate package keyed `@`, created from a version of the source package. The source can be the main package or another branch. + +## Branching Settings + +Configure branching settings on a main package. Currently the only option is whether branching is enabled. + +```bash +content-cli config branch settings set --packageKey --enabled true +content-cli config branch settings set --packageKey --enabled false +``` + +The `--packageKey` must be the main package key, not a `@` value. + +## Create a Branch + +```bash +content-cli config branch create \ + --packageKey \ + --branchKey \ + --sourceVersion 1.4.0 +``` + +`--validate` runs the server-side validator without persisting and prints a success message instead of the created branch. + +## List Branches + +```bash +content-cli config branch list --packageKey +content-cli config branch list --packageKey --json +``` + +`--json` writes the raw `BranchTransport[]` payload to a file in the working directory. + +Each entry includes `sourcePackageKey` and `sourceVersion`, which together form the join key back to the version the branch was cut from. Multiple branches may share the same source. + +## Delete a Branch + +```bash +content-cli config branch delete \ + --packageKey \ + --branchKey +``` + +- `--packageKey` must be the main package key (no `@`). +- `--branchKey` is the branch to delete. + +## Preview and Apply Merges + +You can inspect the changes a merge would apply with `merge preview`, then apply them with `merge apply`. + +```bash +content-cli config branch merge preview \ + --packageKey \ + --sourceKey \ + --sourceVersion 1.4.0 \ + --json +``` + +`sourcePackageKey` is typically `@`. `` may be a version or `LATEST`. + +The JSON preview contains the conflict layout (`changes.configuration.conflicts`, `changes.metadata.conflicts`) and auto-merge results. + +### Quick merge + +When you do not need to override any node-level resolutions, the resolutions file is optional: + +```bash +content-cli config branch merge apply \ + --packageKey \ + --sourceKey \ + --sourceVersion 1.4.0 +``` + +The CLI posts the merge directly. If the server detects conflicts it returns an error — at that point run `config branch merge preview` to inspect them and re-run `apply` with `-f `. + +Customise the published version without writing a file: + +- `--bump PATCH|MINOR|MAJOR` — pick the bump option (default `PATCH`). +- `--newVersion 1.5.0` — pin an explicit semver. +- `--summary ""` — summary of changes (default `"Merge @"`). + +### With a resolutions file + +Build a `MergeBranchTransport` resolution file from the preview (one entry per node where a custom resolution is needed), then apply: + +```bash +content-cli config branch merge apply \ + --packageKey \ + --file merge-request.json +``` + +- `--sourceKey` / `--sourceVersion` override the file's values when you want to keep one resolution file but target different sources. +- `--bump` / `--newVersion` / `--summary` override the file's `versionCreate` block when set. + +A valid merge body looks like: + +```json +{ + "sourceKey": "my-pkg@feature-a", + "sourceVersion": "1.4.0", + "resolvedPackageConflict": null, + "resolvedNodeConflicts": [ + { "nodeKey": "node-1", "resolution": "ACCEPT_SOURCE" } + ], + "versionCreate": { + "versionBumpOption": "PATCH", + "summaryOfChanges": "Merge feature-a into main" + } +} +``` + +`versionCreate` is filled in this order of precedence (highest wins): + +1. `--newVersion ` flag or `--bump PATCH|MINOR|MAJOR` flag (and `--summary`). +2. Whatever the resolutions file already has under `versionCreate`. +3. Default: `versionBumpOption: PATCH` + `summaryOfChanges: "Merge @"`. + +`resolution` per node accepts `ACCEPT_SOURCE`, `ACCEPT_TARGET`, or `CUSTOM`. `CUSTOM` requires the matching `customConfigurationChanges` / `customMetadataChanges` JSON-Patch ops. + +> **Custom changes are restricted to paths the preview already touched.** Each op in `customConfigurationChanges` / `customMetadataChanges` must reference a `path` that appears in either the preview's `sourceChanges` or `targetChanges` for that node — you cannot introduce edits at paths neither side modified. The server rejects ops at unrelated paths. + +Worked example: the preview reports that for node `node-1`, the source set `/title` to `"Source title"` and the target set `/title` to `"Target title"` (a conflict at `/title`). A valid CUSTOM resolution can pick a third value for `/title`, but it cannot also touch `/description` (which neither side changed): + +```json +{ + "nodeKey": "node-1", + "resolution": "CUSTOM", + "customConfigurationChanges": [ + { "op": "replace", "path": "/title", "value": "Negotiated title" } + ] +} +``` diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 04805f70..48eb3267 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -6,6 +6,7 @@ Content CLI organizes its commands into groups by area. Each group covers a spec |---|-------------------------------------------------------------------------------| | [Studio Commands](./studio-commands.md) | Pull and push packages, assets, spaces, and widgets to and from Studio | | [Config Commands](./config-commands.md) | List, batch export, and import all packages and their configurations | +| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches | | [Deployment Commands](./deployment-commands.md) | Create deployments, list history, check active deployments, and manage targets | | [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors | | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | diff --git a/mkdocs.yaml b/mkdocs.yaml index 7b0bea30..4648ae2f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -13,6 +13,7 @@ nav: - Overview: './user-guide/index.md' - Studio Commands: './user-guide/studio-commands.md' - Config Commands: './user-guide/config-commands.md' + - Branch Commands: './user-guide/branch-commands.md' - T2TC Commands: './user-guide/t2tc-commands.md' - Deployment Commands: './user-guide/deployment-commands.md' - Asset Registry Commands: './user-guide/asset-registry-commands.md' diff --git a/src/commands/configuration-management/branch/api/branch.api.ts b/src/commands/configuration-management/branch/api/branch.api.ts new file mode 100644 index 00000000..6a9f927b --- /dev/null +++ b/src/commands/configuration-management/branch/api/branch.api.ts @@ -0,0 +1,74 @@ +import { HttpClient } from "../../../../core/http/http-client"; +import { Context } from "../../../../core/command/cli-context"; +import { FatalError } from "../../../../core/utils/logger"; +import { + BranchingSettingsTransport, + BranchTransport, + CreateBranchTransport, + MergeBranchTransport, + MergePreviewRequestTransport, + MergePreviewTransport, + PackageVersionCreatedTransport, +} from "../interfaces/branch.interfaces"; + +export class BranchApi { + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async configureBranchingSettings(packageKey: string, transport: BranchingSettingsTransport): Promise { + return this.httpClient() + .put(`/pacman/api/core/packages/${packageKey}/branch-settings`, transport) + .catch(e => { + throw new FatalError(`Problem updating branch settings for package ${packageKey}: ${e}`); + }); + } + + public async createBranch(packageKey: string, transport: CreateBranchTransport, validate: boolean = false): Promise { + const params = new URLSearchParams(); + if (validate) { + params.set("validate", "true"); + } + const query = params.toString().length ? `?${params.toString()}` : ""; + + return this.httpClient() + .post(`/pacman/api/core/packages/${packageKey}/branches${query}`, transport) + .catch(e => { + throw new FatalError(`Problem creating branch '${transport.branchKey}' from package ${packageKey}: ${e}`); + }); + } + + public async listBranches(packageKey: string): Promise { + return this.httpClient() + .get(`/pacman/api/core/packages/${packageKey}/branches`) + .catch(e => { + throw new FatalError(`Problem listing branches for package ${packageKey}: ${e}`); + }); + } + + public async mergePreview(packageKey: string, transport: MergePreviewRequestTransport): Promise { + return this.httpClient() + .post(`/pacman/api/core/staging/packages/${packageKey}/merge/preview`, transport) + .catch(e => { + throw new FatalError(`Problem previewing merge into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`); + }); + } + + public async merge(packageKey: string, transport: MergeBranchTransport): Promise { + return this.httpClient() + .post(`/pacman/api/core/staging/packages/${packageKey}/merge`, transport) + .catch(e => { + throw new FatalError(`Problem merging into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`); + }); + } + + public async deleteBranch(branchPackageKey: string): Promise { + return this.httpClient() + .delete(`/pacman/api/core/staging/packages/${branchPackageKey}/purge`) + .catch(e => { + throw new FatalError(`Problem deleting branch ${branchPackageKey}: ${e}`); + }); + } +} diff --git a/src/commands/configuration-management/branch/branch.command.service.ts b/src/commands/configuration-management/branch/branch.command.service.ts new file mode 100644 index 00000000..f0a87a8b --- /dev/null +++ b/src/commands/configuration-management/branch/branch.command.service.ts @@ -0,0 +1,183 @@ +import { v4 as uuidv4 } from "uuid"; +import { Context } from "../../../core/command/cli-context"; +import { fileService, FileService } from "../../../core/utils/file-service"; +import { logger } from "../../../core/utils/logger"; +import { BranchApi } from "./api/branch.api"; +import { + BranchTransport, + BranchingSettingsTransport, + CreateBranchTransport, + MergeApplyOptions, + MergeBranchTransport, + MergePreviewRequestTransport, + MergePreviewTransport, + PackageVersionCreatedTransport, + SavePackageVersionTransport, + VersionBumpOption, +} from "./interfaces/branch.interfaces"; + +export class BranchCommandService { + private readonly branchApi: BranchApi; + + constructor(context: Context) { + this.branchApi = new BranchApi(context); + } + + public async setBranchingEnabled(packageKey: string, enabled: boolean, jsonResponse: boolean): Promise { + const transport: BranchingSettingsTransport = { branchingEnabled: enabled }; + const result = await this.branchApi.configureBranchingSettings(packageKey, transport); + + if (jsonResponse) { + BranchCommandService.writeJson(result); + } else { + logger.info(`Branching ${result.branchingEnabled ? "enabled" : "disabled"} for package ${packageKey}.`); + } + return result; + } + + public async createBranch(packageKey: string, branchKey: string, sourceVersion: string, validate: boolean, jsonResponse: boolean): Promise { + const transport: CreateBranchTransport = { branchKey, version: sourceVersion }; + const result = await this.branchApi.createBranch(packageKey, transport, validate); + + if (validate) { + logger.info(`Validation successful for branch '${branchKey}' from ${packageKey}@${sourceVersion}.`); + return; + } + + if (jsonResponse) { + BranchCommandService.writeJson(result); + } else if (result) { + BranchCommandService.printBranch(result); + } + return result; + } + + public async listBranches(packageKey: string, jsonResponse: boolean): Promise { + const branches = await this.branchApi.listBranches(packageKey); + + if (jsonResponse) { + BranchCommandService.writeJson(branches); + } else if (branches.length === 0) { + logger.info(`No branches found for ${packageKey}.`); + } else { + branches.forEach(branch => { + logger.info( + `${branch.branchKey} (package: ${branch.packageKey}, source: ${branch.sourcePackageKey}@${branch.sourceVersion})` + ); + }); + } + return branches; + } + + public async deleteBranch(packageKey: string, branchKey: string): Promise { + const branchPackageKey = `${packageKey}@${branchKey}`; + await this.branchApi.deleteBranch(branchPackageKey); + logger.info(`Branch ${branchPackageKey} deleted.`); + } + + public async mergePreview(targetPackageKey: string, sourceKey: string, sourceVersion: string, jsonResponse: boolean): Promise { + const transport: MergePreviewRequestTransport = { sourceKey, sourceVersion }; + const preview = await this.branchApi.mergePreview(targetPackageKey, transport); + + if (jsonResponse) { + BranchCommandService.writeJson(preview); + } else { + BranchCommandService.printPreviewSummary(targetPackageKey, sourceKey, sourceVersion, preview); + } + return preview; + } + + public async mergeApply(targetPackageKey: string, options: MergeApplyOptions): Promise { + const rawBody: Partial = options.file + ? JSON.parse(fileService.readFile(options.file)) + : {}; + + const effectiveSourceKey = options.sourceKey ?? rawBody.sourceKey; + const effectiveSourceVersion = options.sourceVersion ?? rawBody.sourceVersion; + + const versionCreate = BranchCommandService.buildVersionCreate( + rawBody.versionCreate, + options.bump, + options.version, + options.summary, + effectiveSourceKey, + effectiveSourceVersion, + ); + + const transport: MergeBranchTransport = { + ...rawBody, + sourceKey: effectiveSourceKey, + sourceVersion: effectiveSourceVersion, + versionCreate, + }; + const result = await this.branchApi.merge(targetPackageKey, transport); + + if (options.jsonResponse) { + BranchCommandService.writeJson(result); + } else { + logger.info( + `Merge applied: published ${result.packageKey}@${result.version} from ${effectiveSourceKey}@${effectiveSourceVersion}.` + ); + } + return result; + } + + private static buildVersionCreate( + fromFile: SavePackageVersionTransport | undefined, + flagBump: string | undefined, + flagVersion: string | undefined, + flagSummary: string | undefined, + sourceKey: string | undefined, + sourceVersion: string | undefined, + ): SavePackageVersionTransport { + const versionCreate: SavePackageVersionTransport = fromFile ? { ...fromFile } : {}; + + if (flagVersion !== undefined) { + versionCreate.version = flagVersion; + versionCreate.versionBumpOption = undefined; + } else if (flagBump !== undefined) { + versionCreate.versionBumpOption = flagBump.toUpperCase() as SavePackageVersionTransport["versionBumpOption"]; + versionCreate.version = undefined; + } else if (!versionCreate.version && !versionCreate.versionBumpOption) { + versionCreate.versionBumpOption = VersionBumpOption.PATCH; + } + + if (flagSummary !== undefined) { + versionCreate.summaryOfChanges = flagSummary; + } else if (!versionCreate.summaryOfChanges && sourceKey && sourceVersion) { + versionCreate.summaryOfChanges = `Merge ${sourceKey}@${sourceVersion}`; + } + + return versionCreate; + } + + private static writeJson(payload: unknown): void { + const filename = `${uuidv4()}.json`; + fileService.writeToFileWithGivenName(JSON.stringify(payload, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private static printBranch(branch: BranchTransport): void { + logger.info(`Main Package Key: ${branch.projectKey}`); + logger.info(`Branch Key: ${branch.branchKey}`); + logger.info(`Package Key: ${branch.packageKey}`); + logger.info(`Source Package Key: ${branch.sourcePackageKey}`); + logger.info(`Source Version: ${branch.sourceVersion}`); + } + + private static printPreviewSummary(targetPackageKey: string, sourceKey: string, sourceVersion: string, preview: MergePreviewTransport): void { + logger.info(`Preview: merge ${sourceKey}@${sourceVersion} into ${targetPackageKey}`); + logger.info(`Common ancestor: ${preview.commonPackageKey}@${preview.commonVersion}`); + const totalNodes = preview.nodeChanges?.length ?? 0; + const packageConflicts = + (preview.packageChanges?.changes?.configuration?.conflicts?.length ?? 0) + + (preview.packageChanges?.changes?.metadata?.conflicts?.length ?? 0); + const nodeConflicts = preview.nodeChanges?.reduce((sum, node) => { + const config = node.changes?.configuration?.conflicts?.length ?? 0; + const metadata = node.changes?.metadata?.conflicts?.length ?? 0; + return sum + config + metadata; + }, 0) ?? 0; + const conflicts = packageConflicts + nodeConflicts; + logger.info(`Nodes touched: ${totalNodes}, conflicting paths: ${conflicts}.`); + } +} diff --git a/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts new file mode 100644 index 00000000..2ad7bfd1 --- /dev/null +++ b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts @@ -0,0 +1,157 @@ +export enum MergeResolution { + CUSTOM = "CUSTOM", + ACCEPT_SOURCE = "ACCEPT_SOURCE", + ACCEPT_TARGET = "ACCEPT_TARGET", +} + +export enum MergeStatus { + UNKNOWN = "UNKNOWN", + UNCHANGED = "UNCHANGED", + SOURCE_ONLY = "SOURCE_ONLY", + TARGET_ONLY = "TARGET_ONLY", + AUTO_MERGE = "AUTO_MERGE", + CONFLICT = "CONFLICT", +} + +export enum ChangeType { + UNKNOWN = "UNKNOWN", + ADDED = "ADDED", + DELETED = "DELETED", + CHANGED = "CHANGED", + UNCHANGED = "UNCHANGED", +} + +export enum VersionBumpOption { + NONE = "NONE", + PATCH = "PATCH", + MINOR = "MINOR", + MAJOR = "MAJOR", +} + +export interface BranchingSettingsTransport { + branchingEnabled: boolean; +} + +export interface CreateBranchTransport { + branchKey: string; + version: string; +} + +export interface BranchTransport { + projectKey: string; + branchKey: string; + sourcePackageKey: string; + sourceVersion: string; + packageKey: string; + packageId?: string; +} + +export interface ConfigurationChangeTransport { + op: string; + path: string; + from?: string; + value?: unknown; + fromValue?: unknown; +} + +export interface MergePackageResolutionTransport { + resolution: MergeResolution; + customConfigurationChanges?: ConfigurationChangeTransport[]; + customMetadataChanges?: ConfigurationChangeTransport[]; +} + +export interface MergeNodeResolutionTransport { + nodeKey: string; + resolution: MergeResolution; + customConfigurationChanges?: ConfigurationChangeTransport[]; + customMetadataChanges?: ConfigurationChangeTransport[]; +} + +export interface NodeFilterTransport { + nodeKeys?: string[]; +} + +export interface SavePackageVersionTransport { + version?: string | null; + versionBumpOption?: VersionBumpOption; + summaryOfChanges?: string; + nodeFilter?: NodeFilterTransport | null; +} + +export interface MergePreviewRequestTransport { + sourceKey: string; + sourceVersion: string; +} + +export interface MergeBranchTransport { + sourceKey: string; + sourceVersion: string; + resolvedPackageConflict?: MergePackageResolutionTransport | null; + resolvedNodeConflicts?: MergeNodeResolutionTransport[]; + versionCreate: SavePackageVersionTransport; +} + +export interface MergePathConflictTransport { + path: string; + sourceChange: ConfigurationChangeTransport; + targetChange: ConfigurationChangeTransport; +} + +export interface MergeDiffDetailTransport { + status: MergeStatus; + sourceChanges: ConfigurationChangeTransport[]; + targetChanges: ConfigurationChangeTransport[]; + conflicts: MergePathConflictTransport[]; + autoMergedChanges: ConfigurationChangeTransport[]; +} + +export interface MergeDiffTransport { + configuration: MergeDiffDetailTransport; + metadata: MergeDiffDetailTransport; +} + +export interface MergePreviewPackageTransport { + sourceChange: ChangeType; + targetChange: ChangeType; + changeDate: string; + updatedBy: string; + changes: MergeDiffTransport; +} + +export interface MergePreviewNodeTransport { + nodeKey: string; + name: string; + type: string; + parentNodeKey?: string; + invalidContent: boolean; + sourceChange: ChangeType; + targetChange: ChangeType; + changeDate: string; + updatedBy: string; + changes: MergeDiffTransport; +} + +export interface MergePreviewTransport { + commonPackageKey: string; + commonVersion: string; + packageChanges: MergePreviewPackageTransport; + nodeChanges: MergePreviewNodeTransport[]; +} + +export interface PackageVersionCreatedTransport { + packageKey: string; + version: string; + summaryOfChanges?: string; + creationDate: string; + createdBy: string; +} + +export interface MergeApplyOptions { + sourceKey?: string; + sourceVersion?: string; + file?: string; + bump?: string; + version?: string; + summary?: string; + jsonResponse?: boolean; +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index a1aee91d..12d13169 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -17,12 +17,71 @@ import { PackageVersionCommandService } from "./package-version-command.service" import { PackageValidationService } from "./package-validation.service"; import { SinglePackageImportService } from "./single-package-import.service"; import { SinglePackageExportService } from "./single-package-export.service"; +import { BranchCommandService } from "./branch/branch.command.service"; class Module extends IModule { public register(context: Context, configurator: Configurator): void { const configCommand = configurator.command("config") .description("Manage package configurations and their resources (package, nodes, versions, variables, metadata). Note: 'config list/export/import/diff' are deprecated — bulk Team-to-Team Copy operations have moved to the 't2tc package' group, and single-package operations live under 'config package'."); + + const branchCommand = configCommand.command("branch").beta() + .description("Author, merge, and mirror package branches"); + + const branchSettingsCommand = branchCommand.command("settings").beta() + .description("Commands to manage branch settings on a package"); + + branchSettingsCommand.command("set").beta() + .description("Configure branch settings on a main package") + .requiredOption("--packageKey ", "Main package key") + .requiredOption("--enabled ", "true|false") + .option("--json", "Write response to a JSON file", false) + .action(this.setBranchSettings); + + branchCommand.command("create").beta() + .description("Create a new branch from a source version") + .requiredOption("--packageKey ", "Main package key to branch from") + .requiredOption("--branchKey ", "New branch key") + .requiredOption("--sourceVersion ", "Source version to branch from") + .option("--validate", "Only validate the request without creating the branch", false) + .option("--json", "Write response to a JSON file", false) + .action(this.createBranch); + + branchCommand.command("list").beta() + .description("List branches of a main package") + .requiredOption("--packageKey ", "Main package key") + .option("--json", "Write response to a JSON file", false) + .action(this.listBranches); + + branchCommand.command("delete").beta() + .description("Delete a branch") + .requiredOption("--packageKey ", "Main package key (no '@')") + .requiredOption("--branchKey ", "Branch key to delete") + .action(this.deleteBranch); + + const branchMergeCommand = branchCommand.command("merge").beta() + .description("Commands to preview and apply branch merges"); + + branchMergeCommand.command("preview").beta() + .description("Preview the changes a merge would apply to a target branch") + .requiredOption("--packageKey ", "Target package key (main package key or '@')") + .requiredOption("--sourceKey ", "Source package key (main package key or '@')") + .requiredOption("--sourceVersion ", "Source version (or 'LATEST')") + .option("--json", "Write response to a JSON file", false) + .action(this.previewBranchMerge); + + branchMergeCommand.command("apply").beta() + .description("Merge changes from a source into a target branch") + .requiredOption("--packageKey ", "Target package key (main package key or '@')") + .option("-f, --file ", "Path to a JSON file containing the MergeBranchTransport payload") + .option("--sourceKey ", "Source package key, main package key or '@' (overrides the file's 'sourceKey')") + .option("--sourceVersion ", "Source version (overrides the file's 'sourceVersion'; or 'LATEST')") + .option("--bump ", "Version bump for the new published version: PATCH | MINOR | MAJOR") + .option("--newVersion ", "Pin the new published version to an explicit semver") + .option("--summary ", "Summary of changes for the new published version") + .option("--json", "Write response to a JSON file", false) + .action(this.applyBranchMerge); + configCommand.command("list") .description("[Deprecated] Use 't2tc package list' instead. List packages in the target team.") .deprecationNotice("'config list' is deprecated and will be removed in a future release. Use 't2tc package list' instead.") @@ -239,6 +298,42 @@ class Module extends IModule { .action(this.listAssignments); } + private async setBranchSettings(context: Context, command: Command, options: OptionValues): Promise { + const enabled = String(options.enabled).toLowerCase(); + if (enabled !== "true" && enabled !== "false") { + throw new Error("Please provide either 'true' or 'false' for --enabled."); + } + await new BranchCommandService(context).setBranchingEnabled(options.packageKey, enabled === "true", !!options.json); + } + + private async createBranch(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).createBranch(options.packageKey, options.branchKey, options.sourceVersion, !!options.validate, !!options.json); + } + + private async listBranches(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).listBranches(options.packageKey, !!options.json); + } + + private async deleteBranch(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).deleteBranch(options.packageKey, options.branchKey); + } + + private async previewBranchMerge(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).mergePreview(options.packageKey, options.sourceKey, options.sourceVersion, !!options.json); + } + + private async applyBranchMerge(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).mergeApply(options.packageKey, { + sourceKey: options.sourceKey, + sourceVersion: options.sourceVersion, + file: options.file, + bump: options.bump, + version: options.newVersion, + summary: options.summary, + jsonResponse: !!options.json, + }); + } + private async listPackages(context: Context, command: Command, options: OptionValues): Promise { if (options.staging && (options.withDependencies || options.packageKeys || options.keysByVersion || options.variableValue || options.variableType)) { throw new Error("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); diff --git a/tests/commands/configuration-management/branch/branch-create.spec.ts b/tests/commands/configuration-management/branch/branch-create.spec.ts new file mode 100644 index 00000000..06ae72ff --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-create.spec.ts @@ -0,0 +1,55 @@ +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { FileService } from "../../../../src/core/utils/file-service"; +import { BranchTransport, CreateBranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch create", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`; + const validateUrl = `${apiUrl}?validate=true`; + + const created: BranchTransport = { + projectKey: packageKey, + branchKey: "feature-a", + sourcePackageKey: packageKey, + sourceVersion: "1.4.0", + packageKey: `${packageKey}@feature-a`, + }; + + it("creates a branch and prints summary", async () => { + mockAxiosPost(apiUrl, created); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, false); + + const requestBody: CreateBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl) as string); + expect(requestBody).toEqual({ branchKey: "feature-a", version: "1.4.0" }); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes("Branch Key: feature-a"))).toBe(true); + }); + + it("hits the validate endpoint when --validate is set", async () => { + mockAxiosPost(validateUrl, undefined); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", true, false); + + expect(mockedPostRequestBodyByUrl.get(validateUrl)).toBeDefined(); + expect( + loggingTestTransport.logMessages.some((m) => m.message.includes(FileService.fileDownloadedMessage)) + ).toBe(false); + expect( + loggingTestTransport.logMessages.some((m) => + m.message.includes(`Validation successful for branch 'feature-a' from ${packageKey}@1.4.0.`) + ) + ).toBe(true); + }); + + it("writes JSON file when jsonResponse=true", async () => { + mockAxiosPost(apiUrl, created); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, true); + + expect(getJsonFromDownloadedFile()).toEqual(created); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-delete.spec.ts b/tests/commands/configuration-management/branch/branch-delete.spec.ts new file mode 100644 index 00000000..0d4a732d --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-delete.spec.ts @@ -0,0 +1,21 @@ +import { mockAxiosDelete, mockedAxiosInstance } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; + +describe("branch delete", () => { + const packageKey = "my-package"; + const branchKey = "feature-a"; + const branchPackageKey = `${packageKey}@${branchKey}`; + const purgeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/purge`; + + it("DELETEs the branch's package and logs a success message", async () => { + mockAxiosDelete(purgeUrl); + + await new BranchCommandService(testContext).deleteBranch(packageKey, branchKey); + + expect(mockedAxiosInstance.delete).toHaveBeenCalledTimes(1); + expect((mockedAxiosInstance.delete as jest.Mock).mock.calls[0][0]).toBe(purgeUrl); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Branch ${branchPackageKey} deleted.`))).toBe(true); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-list.spec.ts b/tests/commands/configuration-management/branch/branch-list.spec.ts new file mode 100644 index 00000000..8786c973 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-list.spec.ts @@ -0,0 +1,54 @@ +import { mockAxiosGet } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { BranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch list", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`; + + const branches: BranchTransport[] = [ + { + projectKey: packageKey, + branchKey: "feature-a", + sourcePackageKey: packageKey, + sourceVersion: "1.4.0", + packageKey: `${packageKey}@feature-a`, + }, + { + projectKey: packageKey, + branchKey: "release", + sourcePackageKey: packageKey, + sourceVersion: "1.5.0", + packageKey: `${packageKey}@release`, + }, + ]; + + it("lists branches and prints one line per branch", async () => { + mockAxiosGet(apiUrl, branches); + + await new BranchCommandService(testContext).listBranches(packageKey, false); + + const messages = loggingTestTransport.logMessages.map((m) => m.message); + expect(messages.some((m) => m.includes(`feature-a (package: ${packageKey}@feature-a`))).toBe(true); + expect(messages.some((m) => m.includes(`release (package: ${packageKey}@release`))).toBe(true); + }); + + it("logs a friendly message when there are no branches", async () => { + mockAxiosGet(apiUrl, []); + + await new BranchCommandService(testContext).listBranches(packageKey, false); + + expect(loggingTestTransport.logMessages[0].message).toContain(`No branches found for ${packageKey}.`); + }); + + it("writes the raw transport list to a JSON file when jsonResponse=true", async () => { + mockAxiosGet(apiUrl, branches); + + await new BranchCommandService(testContext).listBranches(packageKey, true); + + expect(getJsonFromDownloadedFile()).toEqual(branches); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-merge.spec.ts b/tests/commands/configuration-management/branch/branch-merge.spec.ts new file mode 100644 index 00000000..bd2616b9 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-merge.spec.ts @@ -0,0 +1,234 @@ +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile, writeJsonTempFile } from "../../../utls/fs-utils"; +import { + ChangeType, + MergeBranchTransport, + MergePreviewTransport, + MergeResolution, + MergeStatus, + PackageVersionCreatedTransport, + VersionBumpOption, +} from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch merge preview", () => { + const targetPackageKey = "my-package"; + const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`; + + const preview: MergePreviewTransport = { + commonPackageKey: targetPackageKey, + commonVersion: "1.2.0", + packageChanges: { + sourceChange: ChangeType.UNCHANGED, + targetChange: ChangeType.UNCHANGED, + changeDate: new Date().toISOString(), + updatedBy: "user", + changes: { + configuration: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + metadata: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + }, + }, + nodeChanges: [ + { + nodeKey: "node-1", + name: "Node 1", + type: "VIEW", + invalidContent: false, + sourceChange: ChangeType.CHANGED, + targetChange: ChangeType.UNCHANGED, + changeDate: new Date().toISOString(), + updatedBy: "user", + changes: { + configuration: { + status: MergeStatus.CONFLICT, + sourceChanges: [], + targetChanges: [], + conflicts: [ + { path: "/title", sourceChange: { op: "replace", path: "/title", value: "A" }, targetChange: { op: "replace", path: "/title", value: "B" } }, + ], + autoMergedChanges: [], + }, + metadata: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + }, + }, + ], + }; + + it("posts the source key/version and prints a summary with conflict count", async () => { + mockAxiosPost(previewUrl, preview); + + await new BranchCommandService(testContext).mergePreview( + targetPackageKey, + `${targetPackageKey}@feature-a`, + "1.4.0", + false, + ); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(previewUrl) as string)).toEqual({ + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + }); + const messages = loggingTestTransport.logMessages.map((m) => m.message); + expect(messages.some((m) => m.includes("conflicting paths: 1"))).toBe(true); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPost(previewUrl, preview); + + await new BranchCommandService(testContext).mergePreview(targetPackageKey, "src", "1.4.0", true); + + expect(getJsonFromDownloadedFile()).toEqual(preview); + }); +}); + +describe("branch merge apply", () => { + const targetPackageKey = "my-package"; + const mergeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge`; + const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`; + + const created: PackageVersionCreatedTransport = { + packageKey: targetPackageKey, + version: "1.5.0", + summaryOfChanges: "merge from feature-a", + creationDate: new Date().toISOString(), + createdBy: "user", + }; + + const baseBody: MergeBranchTransport = { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + resolvedNodeConflicts: [ + { nodeKey: "node-1", resolution: MergeResolution.ACCEPT_SOURCE }, + ], + versionCreate: { versionBumpOption: VersionBumpOption.MAJOR, summaryOfChanges: "merge from feature-a" }, + }; + + beforeEach(() => { + writeJsonTempFile("merge-request.json", baseBody); + }); + + it("merges using values from the body when flags are omitted", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe(baseBody.sourceKey); + expect(sent.sourceVersion).toBe(baseBody.sourceVersion); + expect(sent.resolvedNodeConflicts).toEqual(baseBody.resolvedNodeConflicts); + expect(sent.versionCreate).toEqual(baseBody.versionCreate); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Merge applied: published ${targetPackageKey}@1.5.0`))).toBe(true); + }); + + it("overrides source from flags when provided", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: "override-key", + sourceVersion: "LATEST", + file: "merge-request.json", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe("override-key"); + expect(sent.sourceVersion).toBe("LATEST"); + }); + + it("flag --bump overrides versionCreate.versionBumpOption from the file", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + bump: "minor", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate.versionBumpOption).toBe(VersionBumpOption.MINOR); + expect(sent.versionCreate.version).toBeUndefined(); + }); + + it("flag --version overrides versionCreate.version and clears bump", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + version: "2.0.0", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate.version).toBe("2.0.0"); + expect(sent.versionCreate.versionBumpOption).toBeUndefined(); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + jsonResponse: true, + }); + + expect(getJsonFromDownloadedFile()).toEqual(created); + }); + + describe("without a resolutions file", () => { + it("posts to the merge endpoint directly with default versionCreate", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + }); + + expect(mockedPostRequestBodyByUrl.get(previewUrl)).toBeUndefined(); + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe(`${targetPackageKey}@feature-a`); + expect(sent.sourceVersion).toBe("1.4.0"); + expect(sent.resolvedNodeConflicts).toBeUndefined(); + expect(sent.versionCreate).toEqual({ + versionBumpOption: VersionBumpOption.PATCH, + summaryOfChanges: `Merge ${targetPackageKey}@feature-a@1.4.0`, + }); + }); + + it("honours flags for bump and summary", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + bump: "MAJOR", + summary: "ship it", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate).toEqual({ + versionBumpOption: VersionBumpOption.MAJOR, + summaryOfChanges: "ship it", + }); + }); + }); + +}); diff --git a/tests/commands/configuration-management/branch/branch-settings.spec.ts b/tests/commands/configuration-management/branch/branch-settings.spec.ts new file mode 100644 index 00000000..30987013 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-settings.spec.ts @@ -0,0 +1,36 @@ +import { mockAxiosPut, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; + +describe("branch settings set", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branch-settings`; + + it("enables branching and logs status", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: true }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, false); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: true }); + expect(loggingTestTransport.logMessages[0].message).toContain(`Branching enabled for package ${packageKey}.`); + }); + + it("disables branching", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: false }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, false, false); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: false }); + expect(loggingTestTransport.logMessages[0].message).toContain(`Branching disabled for package ${packageKey}.`); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: true }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, true); + + expect(getJsonFromDownloadedFile()).toEqual({ branchingEnabled: true }); + }); +}); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index 7864e492..b7283ec3 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -8,6 +8,7 @@ import { PackageVersionCommandService } from "../../../src/commands/configuratio import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; +import { BranchCommandService } from "../../../src/commands/configuration-management/branch/branch.command.service"; import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/configuration-management/config-command.service"); @@ -19,6 +20,7 @@ jest.mock("../../../src/commands/configuration-management/node-diff.service"); jest.mock("../../../src/commands/configuration-management/package-version-command.service"); jest.mock("../../../src/commands/configuration-management/single-package-import.service"); jest.mock("../../../src/commands/configuration-management/single-package-export.service"); +jest.mock("../../../src/commands/configuration-management/branch/branch.command.service"); describe("configuration-management command integration", () => { let mockConfigCommandService: jest.Mocked; @@ -30,6 +32,7 @@ describe("configuration-management command integration", () => { let mockPackageVersionCommandService: jest.Mocked; let mockSinglePackageImportService: jest.Mocked; let mockSinglePackageExportService: jest.Mocked; + let mockBranchCommandService: jest.Mocked; beforeEach(() => { mockConfigCommandService = { @@ -73,6 +76,11 @@ describe("configuration-management command integration", () => { exportPackage: jest.fn().mockResolvedValue(undefined), } as any; + mockBranchCommandService = { + setBranchingEnabled: jest.fn().mockResolvedValue(undefined), + mergeApply: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); @@ -82,6 +90,7 @@ describe("configuration-management command integration", () => { (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); + (BranchCommandService as jest.MockedClass).mockImplementation(() => mockBranchCommandService); }); let lastResult: CliRunResult; @@ -268,6 +277,71 @@ describe("configuration-management command integration", () => { }); }); + describe("config branch settings set (setBranchingEnabled)", () => { + it("forwards enabled=true", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "true"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false); + }); + + it("forwards enabled=false", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "false"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", false, false); + }); + + it("accepts --enabled regardless of casing", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "TRUE"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false); + }); + + it("rejects a value that is neither 'true' nor 'false' instead of disabling branching", async () => { + await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "yes"]); + + expectError("Please provide either 'true' or 'false' for --enabled."); + expect(mockBranchCommandService.setBranchingEnabled).not.toHaveBeenCalled(); + }); + }); + + describe("config branch merge apply (mergeApply)", () => { + it("forwards --newVersion, which the root program's --version would otherwise shadow", async () => { + const result = await runCli([ + "config", "branch", "merge", "apply", + "--packageKey", "target", + "--sourceKey", "target@feature-a", + "--sourceVersion", "1.4.0", + "--newVersion", "1.5.0", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({ + sourceKey: "target@feature-a", + sourceVersion: "1.4.0", + version: "1.5.0", + })); + }); + + it("forwards --bump", async () => { + const result = await runCli([ + "config", "branch", "merge", "apply", + "--packageKey", "target", + "--sourceKey", "target@feature-a", + "--sourceVersion", "1.4.0", + "--bump", "MINOR", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({ + bump: "MINOR", + version: undefined, + })); + }); + }); + describe("config export (deprecated batchExportPackages)", () => { it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([