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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion docs/user-guide/branch-commands.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Branch Commands (beta)

The `config branch` command group lets you author and merge branches from the CLI, and optionally mirror a branch to a Git branch one-to-one.
The `config branch` command group lets you author and merge branches from the CLI, and optionally mirror a branch to a Git branch one-to-one. The related [`config pointer`](#select-a-branch-as-live-config-pointer) group selects which branch a package's consumers read.

## Concepts

Expand Down Expand Up @@ -143,6 +143,69 @@ Worked example: the preview reports that for node `node-1`, the source set `/tit
}
```

## Select a branch as LIVE (`config pointer`)

The `config pointer` group decides which branch of a package its consumers read. Selecting a branch as
LIVE is how you release a branch without merging it back into main.

This group needs the `pacman.live-branch-pointer` feature to be active for the team.

### Concepts

- **LIVE selection** — a named pointer on a main package. While it is set, a consumer that references
the main package resolves to the selected branch instead. Consumers keep referencing the plain
`<packageKey>`, so moving the selection to a new branch needs no change on their side.
- **No selection** — the default. Consumers read the main package.

Both commands take `--packageKey` as the **main** package key. Passing a `<packageKey>@<branchKey>`
value is rejected before any request is sent; name the branch with `--branchKey` instead.

### Set the LIVE selection

```bash
content-cli config pointer set --packageKey <packageKey> --branchKey <branchKey>
```

`--json` writes the raw `PackagePointerTransport` payload to a file in the working directory.

There is no dry-run flag. `set` always changes the selection when it succeeds, so validate the branch
first with `config package validate --packageKey <packageKey>@<branchKey>`.

`main` cannot be selected. To return consumers to the main package, merge the branch into main with
[`config branch merge apply`](#preview-and-apply-merges) — there is no CLI command that clears a
selection, because leaving consumers pointed at nothing is not a state the CLI will produce.

### Read the LIVE selection

```bash
content-cli config pointer get --packageKey <packageKey>
content-cli config pointer get --packageKey <packageKey> --json
```

When no branch is selected, the command reports that and exits successfully. With `--json` it writes
`null`.

### Responses worth recognizing

| Response | Meaning | What to do |
|---|---|---|
| `409` with `package-pointer-blocking-problems` | The branch has problems that block a release. | Run `config package validate --packageKey <packageKey>@<branchKey>`, fix what it reports, then set the selection again. There is no override flag. |
| `403` | Ambiguous. Either the profile may not edit the package, **or** `pacman.live-branch-pointer` is inactive for the team. The response body is empty and does not distinguish the two. | Confirm the feature is active before asking for permissions. |
| `400` | `--packageKey` was not a main key, or the composed branch key does not name a branch. | Check both keys. `config branch list --packageKey <packageKey>` shows the valid branch keys. |

### Where this sits in a release

```bash
content-cli config package validate --packageKey my-package
content-cli config versions create --packageKey my-package --versionBumpOption PATCH
content-cli config branch create --packageKey my-package --branchKey release-branch --sourceVersion 1.4.0
content-cli config package validate --packageKey my-package@release-branch
content-cli config pointer set --packageKey my-package --branchKey release-branch
```

Run the branch's pipelines and transformations, and refresh any cached perspectives, before the last
step. The problems those steps clear are the same ones the `409` reports.

## Branch export / import

`config branch export` and `config branch import` move a branch's contents in and out of the package. They behave like `config package export` / `config package import`, with one difference: they always rewrite `package.json#key`, so a branch's exported content lines up with the main package's.
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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, and mirror them to Git |
| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches, select which branch is LIVE, and mirror them to Git |
| [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 |
Expand Down
25 changes: 25 additions & 0 deletions src/commands/configuration-management/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SinglePackageImportService } from "./single-package-import.service";
import { SinglePackageExportService } from "./single-package-export.service";
import { BranchCommandService } from "./branch/branch.command.service";
import { BranchExportImportCommandService } from "./branch/branch-export-import.command.service";
import { PointerCommandService } from "./pointer/pointer.command.service";
import { BranchUtils } from "../../core/utils/branches";

class Module extends IModule {
Expand Down Expand Up @@ -105,6 +106,22 @@ class Module extends IModule {
.option("--json", "Write response to a JSON file", false)
.action(this.importBranch);

const pointerCommand = configCommand.command("pointer").beta()
.description("Select which branch of a package consumers read (the LIVE selection)");

pointerCommand.command("set").beta()
.description("Select a branch as LIVE, so consumers of the main package read that branch")
.requiredOption("--packageKey <packageKey>", "Main package key (no '@')")
.requiredOption("--branchKey <branchKey>", "Branch key to select as LIVE")
.option("--json", "Write response to a JSON file", false)
.action(this.setPackagePointer);

pointerCommand.command("get").beta()
.description("Show which branch is currently LIVE for a main package")
.requiredOption("--packageKey <packageKey>", "Main package key (no '@')")
.option("--json", "Write response to a JSON file", false)
.action(this.getPackagePointer);

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.")
Expand Down Expand Up @@ -339,6 +356,14 @@ class Module extends IModule {
await new BranchCommandService(context).deleteBranch(options.packageKey, options.branchKey);
}

private async setPackagePointer(context: Context, command: Command, options: OptionValues): Promise<void> {
await new PointerCommandService(context).setLive(options.packageKey, options.branchKey, !!options.json);
}

private async getPackagePointer(context: Context, command: Command, options: OptionValues): Promise<void> {
await new PointerCommandService(context).getLive(options.packageKey, !!options.json);
}

private async previewBranchMerge(context: Context, command: Command, options: OptionValues): Promise<void> {
await new BranchCommandService(context).mergePreview(options.packageKey, options.sourceKey, options.sourceVersion, !!options.json);
}
Expand Down
117 changes: 117 additions & 0 deletions src/commands/configuration-management/pointer/api/pointer.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { HttpClient } from "../../../../core/http/http-client";
import { Context } from "../../../../core/command/cli-context";
import { FatalError } from "../../../../core/utils/logger";
import {
ConflictErrorTransport,
PackagePointerTransport,
SetPackagePointerTransport,
} from "../interfaces/pointer.interfaces";

export const BLOCKING_PROBLEMS_ERROR_CODE = "package-pointer-blocking-problems";

const STATUS_NO_CONTENT = 204;
const STATUS_FIRST_ERROR = 400;
const STATUS_FORBIDDEN = 403;
const STATUS_NOT_FOUND = 404;
const STATUS_CONFLICT = 409;

const AMBIGUOUS_FORBIDDEN_MESSAGE =
"The package pointer API answered 403 with an empty body. Two causes produce exactly this response and it " +
"does not distinguish them: the profile may not edit the package, or the 'pacman.live-branch-pointer' " +
"feature is inactive for the team. Confirm the feature is active before requesting permissions.";

export interface PointerLookup {
pointer: PackagePointerTransport | null;
detail?: string;
}

export class PointerApi {
private readonly httpClient: () => HttpClient;

constructor(context: Context) {
this.httpClient = () => context.httpClient;
}

public async setPointer(
packageKey: string,
transport: SetPackagePointerTransport,
): Promise<PackagePointerTransport | null> {
const { status, data } = await this.httpClient().putStatusAndData(
PointerApi.pointerUrl(packageKey),
transport,
);

if (status >= STATUS_FIRST_ERROR) {
PointerApi.fail(status, data, transport.branchPackageKey);
}

return PointerApi.hasNoPayload(status, data) ? null : (data as PackagePointerTransport);
}

public async getPointer(packageKey: string): Promise<PointerLookup> {
const { status, data } = await this.httpClient().getStatusAndData(PointerApi.pointerUrl(packageKey));

if (status === STATUS_NOT_FOUND) {
return { pointer: null, detail: PointerApi.messageOf(data) };
}

if (status >= STATUS_FIRST_ERROR) {
PointerApi.fail(status, data, packageKey);
}

if (PointerApi.hasNoPayload(status, data)) {
return { pointer: null };
}

return { pointer: data as PackagePointerTransport };
}

private static pointerUrl(packageKey: string): string {
return `/pacman/api/core/pointers/packages/${encodeURIComponent(packageKey)}`;
}

private static hasNoPayload(status: number, data: unknown): boolean {
return status === STATUS_NO_CONTENT || data === undefined || data === null || data === "";
}

private static fail(status: number, data: unknown, subjectKey: string): never {
if (status === STATUS_FORBIDDEN) {
throw new FatalError(AMBIGUOUS_FORBIDDEN_MESSAGE);
}

if (status === STATUS_CONFLICT) {
throw new FatalError(PointerApi.conflictMessage(data, subjectKey));
}

throw new FatalError(`Package pointer request failed with status ${status}: ${PointerApi.describe(data)}`);
}

private static conflictMessage(data: unknown, subjectKey: string): string {
const body = (data ?? {}) as ConflictErrorTransport;
const errorCode = body.details?.[0]?.errorCode;
const reason = body.message ?? PointerApi.describe(data);
const head = errorCode ? `${reason} (errorCode: ${errorCode})` : reason;

if (errorCode !== BLOCKING_PROBLEMS_ERROR_CODE) {
return head;
}

return (
`${head}\nThe branch has blocking problems, so it cannot be selected as LIVE. ` +
`Resolve them and retry. To list them, run: ` +
`content-cli config package validate --packageKey ${subjectKey}`
);
}

private static messageOf(data: unknown): string | undefined {
const message = (data as { message?: unknown })?.message;
return typeof message === "string" ? message : undefined;
}

private static describe(data: unknown): string {
if (data === undefined || data === null || data === "") {
return "no response body";
}
return typeof data === "string" ? data : JSON.stringify(data);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface PackagePointerTransport {
packageKey: string;
pointerName: string;
branchPackageKey: string;
updatedAt?: string;
updatedBy?: string;
}

export interface SetPackagePointerTransport {
branchPackageKey: string;
}

export interface ConflictErrorDetailsTransport {
errorCode?: string;
}

export interface ConflictErrorTransport {
message?: string;
details?: ConflictErrorDetailsTransport[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { v4 as uuidv4 } from "uuid";
import { Context } from "../../../core/command/cli-context";
import { FileService } from "../../../core/utils/file-service";
import { CuiFileService } from "../../../core/utils/cui-file-service";
import { logger } from "../../../core/utils/logger";
import { BranchUtils } from "../../../core/utils/branches";
import { PointerApi } from "./api/pointer.api";
import { PackagePointerTransport, SetPackagePointerTransport } from "./interfaces/pointer.interfaces";

export class PointerCommandService {
private readonly pointerApi: PointerApi;
private readonly cuiFileService: CuiFileService;

constructor(context: Context) {
this.pointerApi = new PointerApi(context);
this.cuiFileService = new CuiFileService(context);
}

public async setLive(
packageKey: string,
branchKey: string,
jsonResponse: boolean,
): Promise<PackagePointerTransport | null> {
PointerCommandService.requireMainPackageKey(packageKey);

if (branchKey === BranchUtils.MAIN_BRANCH_KEY) {
throw new Error(
`'${BranchUtils.MAIN_BRANCH_KEY}' cannot be selected as LIVE. Select one of the package's branches.`,
);
}

const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey);
const transport: SetPackagePointerTransport = { branchPackageKey };
const result = await this.pointerApi.setPointer(packageKey, transport);

if (jsonResponse) {
await this.writeJson(result);
} else if (result) {
PointerCommandService.printPointer(result);
} else {
logger.info(`${branchPackageKey} is now LIVE for ${packageKey}.`);
}

return result;
}

public async getLive(packageKey: string, jsonResponse: boolean): Promise<PackagePointerTransport | null> {
PointerCommandService.requireMainPackageKey(packageKey);

const { pointer, detail } = await this.pointerApi.getPointer(packageKey);

if (jsonResponse) {
await this.writeJson(pointer);
return pointer;
}

if (pointer) {
PointerCommandService.printPointer(pointer);
return pointer;
}

logger.info(`No LIVE selection for ${packageKey}. Consumers read the main package.`);
if (detail) {
logger.info(detail);
}

return null;
}

private static requireMainPackageKey(packageKey: string): void {
if (BranchUtils.isBranchPackageKey(packageKey)) {
throw new Error(
`--packageKey must be the main package key, without '@'. Received '${packageKey}'. ` +
`Pass the branch through --branchKey instead.`,
);
}
}

private static printPointer(pointer: PackagePointerTransport): void {
logger.info(`Package Key: ${pointer.packageKey}`);
logger.info(`Pointer Name: ${pointer.pointerName}`);
logger.info(`Branch Package Key: ${pointer.branchPackageKey}`);
if (pointer.updatedBy) {
logger.info(`Updated By: ${pointer.updatedBy}`);
}
if (pointer.updatedAt) {
logger.info(`Updated At: ${pointer.updatedAt}`);
}
}

private async writeJson(payload: unknown): Promise<void> {
const filename = `${uuidv4()}.json`;
const writtenFilename = await this.cuiFileService.writeToFileWithGivenName(
JSON.stringify(payload, null, 2),
filename,
);
logger.info(FileService.fileDownloadedMessage + writtenFilename);
}
}
17 changes: 17 additions & 0 deletions src/core/http/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ export class HttpClient {
});
}

public async putStatusAndData(url: string, body: object): Promise<{ status: number; data: any }> {
const fullUrl = this.resolveUrl(url);
logger.debug(`HttpClient - PUT ${fullUrl}`);
return this.axios.put(fullUrl, JSON.stringify(body), {
headers: this.buildHeaders("application/json;charset=utf-8"),
validateStatus: () => true,
}).then(response => {
logger.debug(`Response ${response.status}`);
return { status: response.status, data: response.data };
}).catch(err => {
if (err.response) {
return { status: err.response.status, data: err.response.data };
}
throw new FatalError(err);
});
}

public async getFile(url: string): Promise<any> {
return new Promise<any>((resolve, reject) => {
this.axios.get(this.resolveUrl(url), {
Expand Down
Loading
Loading