Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions packages/spacecat-shared-cloud-manager-client/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Spacecat Shared - Cloud Manager Client

A JavaScript client for Adobe Cloud Manager repository operations. It supports cloning, pulling, pushing, checking out refs, zipping/unzipping repositories, applying patches, and creating pull requests for both **BYOG (Bring Your Own Git)** and **standard** Cloud Manager repositories.
A JavaScript client for Adobe Cloud Manager repository operations. It supports cloning, pulling, pushing, checking out refs, zipping/unzipping repositories, applying patches (from S3 or in-memory strings), writing files directly, and creating pull requests for both **BYOG (Bring Your Own Git)** and **standard** Cloud Manager repositories.

The client is **stateless** with respect to repositories — no repo-specific information is stored on the instance. All repository details (`programId`, `repositoryId`, `imsOrgId`, `repoType`, `repoUrl`) are passed per method call. The only instance-level state is the cached IMS service token (shared across all repos) and generic configuration (CM API base URL, S3 client, git committer identity). This means a single `CloudManagerClient` instance can work across multiple repositories, programs, and IMS orgs within the same session.

Expand Down Expand Up @@ -94,6 +94,7 @@ Both repo types authenticate via `http.extraheader` — no credentials are ever
- Git error output is sanitized before logging — Bearer tokens, Basic auth headers, `x-api-key`, `x-gw-ims-org-id` values, and basic-auth credentials in URLs are all replaced with `[REDACTED]`. Both `stderr`, `stdout`, and `error.message` are sanitized.
- All git commands run with a 120-second timeout to prevent hung processes from blocking the Lambda.
- `GIT_ASKPASS` is explicitly cleared to prevent inherited credential helpers from being invoked.
- All apply methods (`applyPatch`, `applyPatchContent`, `applyFiles`) share a single internal orchestration path that configures git identity, checks out the branch, applies changes, and optionally stages + commits. Patch format detection (mail-message vs plain diff) is also centralized.

## Usage

Expand Down Expand Up @@ -129,6 +130,15 @@ await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.diff'
commitMessage: 'Apply agent suggestion: fix accessibility issue',
});

// Apply a patch from an in-memory string (no S3 download)
await client.applyPatchContent(clonePath, 'feature/fix', patchString, 'fix: apply suggestion');

// Write files directly and commit
await client.applyFiles(clonePath, 'feature/fix', [
{ path: 'src/main.js', content: 'updated content' },
{ path: 'src/utils/helper.js', content: 'new helper' },
], 'fix: update accessibility assets');

// Push (ref is required — specifies the branch to push)
await client.push(clonePath, programId, repositoryId, { imsOrgId, ref: 'feature/fix' });

Expand Down Expand Up @@ -195,7 +205,9 @@ await client.cleanup(clonePath);
- **`zipRepository(clonePath)`** – Zip the clone (including `.git` history) and return a Buffer.
- **`unzipRepository(zipBuffer)`** – Extract a ZIP buffer to a new temp directory and return the path. Used for incremental updates (restore a previously-zipped repo from S3, then `pull` with `ref` instead of a full clone). Cleans up on failure.
- **`createBranch(clonePath, baseBranch, newBranch)`** – Checkout the base branch and create a new branch from it.
- **`applyPatch(clonePath, branch, s3PatchPath, options?)`** – Download a patch from S3 (`s3://bucket/key` format) and apply it. The patch format is detected automatically from the content, not the file extension. Mail-message patches (content starts with `From `) are applied with `git am`, which creates the commit using the embedded metadata. Plain diffs (content starts with `diff `) are applied with `git apply`, staged with `git add -A`, and committed — `options.commitMessage` is required for this flow. If `commitMessage` is provided with a mail-message patch, it is ignored and a warning is logged. Configures committer identity from `ASO_CODE_AUTOFIX_USERNAME`/`ASO_CODE_AUTOFIX_EMAIL`. Cleans up the temp patch file in a `finally` block.
- **`applyPatch(clonePath, branch, s3PatchPath, options?)`** – Download a patch from S3 (`s3://bucket/key` format) and apply it. The patch format is detected automatically from the content. Mail-message patches (starting with `From `) are applied with `git am` (auto-commits using embedded metadata). Plain diffs are applied with `git apply` then staged and committed — `options.commitMessage` is required for this flow. If `commitMessage` is provided with a mail-message patch, it is ignored and a warning is logged. Cleans up the temp patch file in a `finally` block.
- **`applyPatchContent(clonePath, branch, patchContent, commitMessage)`** – Same as `applyPatch`, but takes the patch content as an in-memory string instead of downloading from S3. Useful when the patch is already available (e.g. from suggestion data). The `commitMessage` parameter is required; for mail-message patches it is ignored (logged as info).
- **`applyFiles(clonePath, branch, files, commitMessage)`** – Write files to the clone directory and commit the changes. `files` is an array of `{ path, content }` objects where `path` is relative to the repo root. Parent directories are created automatically. Changes are staged with `git add -A` and committed.
- **`cleanup(clonePath)`** – Remove the clone directory. Validates the path starts with the expected temp prefix to prevent accidental deletion.
- **`createPullRequest(programId, repositoryId, config)`** – Create a PR via the CM Repo API (BYOG only, uses IMS token). Config: `{ imsOrgId, sourceBranch, destinationBranch, title, description }`.

Expand Down
12 changes: 12 additions & 0 deletions packages/spacecat-shared-cloud-manager-client/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ export default class CloudManagerClient {
s3PatchPath: string,
options?: { commitMessage?: string },
): Promise<void>;
applyPatchContent(
clonePath: string,
branch: string,
patchContent: string,
commitMessage: string,
): Promise<void>;
applyFiles(
clonePath: string,
branch: string,
files: Array<{ path: string; content: string }>,
commitMessage: string,
): Promise<void>;
cleanup(clonePath: string): Promise<void>;
createPullRequest(
programId: string,
Expand Down
198 changes: 169 additions & 29 deletions packages/spacecat-shared-cloud-manager-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import { execFileSync } from 'child_process';
import {
existsSync, mkdtempSync, readdirSync, readFileSync,
existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
readlinkSync, rmSync, statfsSync, writeFileSync,
} from 'fs';
import os from 'os';
Expand Down Expand Up @@ -230,6 +230,33 @@ export default class CloudManagerClient {
}
}

/**
* Common orchestration for applying changes to a cloned repository.
* Configures git identity, checks out the branch, runs the caller-provided
* apply function, and optionally stages + commits.
*
* @param {string} clonePath - Path to the cloned repository
* @param {string} branch - Branch to checkout
* @param {Function} applyFn - Async callback that applies changes to the working tree.
* Receives `clonePath` as its only argument.
* @param {string|null} commitMessage - If provided, runs `git add -A` + `git commit`
* after applyFn completes. Pass null when the apply step already commits
* (e.g. `git am` for mail-message patches).
*/
async #applyChanges(clonePath, branch, applyFn, commitMessage) {
const { gitUsername, gitUserEmail } = this.config;
this.#execGit(['config', 'user.name', gitUsername], { cwd: clonePath });
this.#execGit(['config', 'user.email', gitUserEmail], { cwd: clonePath });
this.#execGit(['checkout', branch], { cwd: clonePath });

await applyFn(clonePath);

if (commitMessage) {
this.#execGit(['add', '-A'], { cwd: clonePath });
this.#execGit(['commit', '-m', commitMessage], { cwd: clonePath });
}
}

/**
* Builds authenticated git arguments for a remote command (clone, push, or pull).
*
Expand Down Expand Up @@ -376,6 +403,20 @@ export default class CloudManagerClient {
this.#execGit(['checkout', '-b', newBranch], { cwd: clonePath });
}

/**
* Checks whether the given patch content is in git mail-message format.
* Mail-message patches (generated by `git format-patch`) start with "From "
* and are applied via `git am`, which auto-commits using embedded metadata.
* Plain diffs start with "diff " and require explicit `git apply` + commit.
*
* @param {string} patchContent - Raw patch content
* @returns {boolean} true if mail-message format
*/
// eslint-disable-next-line class-methods-use-this
#isMailFormatPatch(patchContent) {
return patchContent.startsWith('From ');
}

/**
* Downloads a patch from S3 and applies it to the given branch.
* Supports two patch formats, detected automatically from the content:
Expand All @@ -395,7 +436,6 @@ export default class CloudManagerClient {
const { bucket, key } = parseS3Path(s3PatchPath);
const patchDir = mkdtempSync(path.join(os.tmpdir(), PATCH_FILE_PREFIX));
const patchFile = path.join(patchDir, 'applied.patch');
const { gitUsername, gitUserEmail } = this.config;

try {
// Download patch from S3
Expand All @@ -405,42 +445,95 @@ export default class CloudManagerClient {
const patchContent = await response.Body.transformToString();
writeFileSync(patchFile, patchContent);

// Configure committer identity
this.#execGit(['config', 'user.name', gitUsername], { cwd: clonePath });
this.#execGit(['config', 'user.email', gitUserEmail], { cwd: clonePath });

// Checkout branch
this.#execGit(['checkout', branch], { cwd: clonePath });

// Detect format from content and apply accordingly
const isMailMessage = patchContent.startsWith('From ');
const { commitMessage } = options;
const isMailFormat = this.#isMailFormatPatch(patchContent);

if (isMailMessage) {
// Mail-message format: git am creates the commit using embedded metadata
if (commitMessage) {
this.log.warn('commitMessage is ignored for mail-message patches; git am uses the embedded commit message');
}
this.#execGit(['am', patchFile], { cwd: clonePath });
} else {
// Plain diff format: apply, stage, and commit
if (!commitMessage) {
throw new Error('commitMessage is required when applying a plain diff patch');
}
this.#execGit(['apply', patchFile], { cwd: clonePath });
this.#execGit(['add', '-A'], { cwd: clonePath });
this.#execGit(['commit', '-m', commitMessage], { cwd: clonePath });
if (!isMailFormat && !commitMessage) {
throw new Error('commitMessage is required when applying a plain diff patch');
}
if (isMailFormat && commitMessage) {
this.log.warn('commitMessage is ignored for mail-message patches; git am uses the embedded commit message');
}

await this.#applyChanges(clonePath, branch, () => {
this.#execGit([isMailFormat ? 'am' : 'apply', patchFile], { cwd: clonePath });
}, isMailFormat ? null : commitMessage);
this.log.info(`Patch applied and committed on branch ${branch}`);
} finally {
// Clean up temp patch directory and file
if (existsSync(patchDir)) {
rmSync(patchDir, { recursive: true, force: true });
}
}
}

/**
* Applies a patch from an in-memory string (not from S3).
* Use this when the patch content is already available (e.g. from suggestion data)
* instead of downloading from S3. Writes the content to a temp file, applies it,
* then cleans up.
* @param {string} clonePath - Path to the cloned repository
* @param {string} branch - Branch to apply the patch on
* @param {string} patchContent - The patch/diff content as a string
* @param {string} commitMessage - Commit message for the applied changes
*/
async applyPatchContent(clonePath, branch, patchContent, commitMessage) {
if (!commitMessage) {
throw new Error('commitMessage is required for applyPatchContent');
}

const patchDir = mkdtempSync(path.join(os.tmpdir(), PATCH_FILE_PREFIX));
const patchFile = path.join(patchDir, 'applied.patch');

try {
writeFileSync(patchFile, patchContent);

const isMailFormat = this.#isMailFormatPatch(patchContent);
await this.#applyChanges(clonePath, branch, () => {
this.#execGit([isMailFormat ? 'am' : 'apply', patchFile], { cwd: clonePath });
}, isMailFormat ? null : commitMessage);

if (isMailFormat) {
this.log.info(`Mail-message patch applied via git am on branch ${branch} (commitMessage ignored)`);
} else {
this.log.info(`Plain diff patch applied and committed on branch ${branch}`);
}
} finally {
if (existsSync(patchDir)) {
rmSync(patchDir, { recursive: true, force: true });
}
}
}

/**
* Writes files to the clone directory and commits the changes.
* Use this for file-based updates (e.g. accessibility S3 assets) where the
* content is provided as an array of {path, content} objects rather than a diff.
* @param {string} clonePath - Path to the cloned repository
* @param {string} branch - Branch to apply the files on
* @param {{path: string, content: string}[]} files - Files to write (relative paths)
* @param {string} commitMessage - Commit message
*/
async applyFiles(clonePath, branch, files, commitMessage) {
if (!commitMessage) {
throw new Error('commitMessage is required for applyFiles');
}
if (!Array.isArray(files) || files.length === 0) {
throw new Error('files must be a non-empty array of {path, content} objects');
}

await this.#applyChanges(clonePath, branch, (cwd) => {
for (const file of files) {
const filePath = path.join(cwd, file.path);
const dir = path.dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, file.content);
}
}, commitMessage);
this.log.info(`${files.length} file(s) applied and committed on branch ${branch}`);
}

/**
* Pushes the current branch to the remote CM repository.
* Commits are expected to already exist (e.g. via applyPatch).
Expand Down Expand Up @@ -555,6 +648,41 @@ export default class CloudManagerClient {
}
}

/**
* PR/MR path patterns per git provider.
* Maps CM_REPO_TYPE to the URL path template used for pull/merge requests.
*/
#PR_PATH_BY_PROVIDER = Object.freeze({
[CM_REPO_TYPE.GITHUB]: (n) => `/pull/${n}`,
[CM_REPO_TYPE.GITLAB]: (n) => `/-/merge_requests/${n}`,
});

/**
* Builds the pull request URL from the external repo URL and PR number.
* Detects the git provider from the repo URL to use the correct path format.
* Returns null if the provider is not recognized.
*
* @param {string} repoUrl - External repository URL (e.g. https://github.com/owner/repo.git)
* @param {string} externalNumber - PR/MR number from the CM API response
* @returns {string|null} Full pull request URL, or null if provider is unsupported
*/
#buildPullRequestUrl(repoUrl, externalNumber) {
let provider = null;
if (repoUrl.includes('github.com') || repoUrl.includes('github.')) {
Comment thread Dismissed
provider = CM_REPO_TYPE.GITHUB;
} else if (repoUrl.includes('gitlab.com') || repoUrl.includes('gitlab.')) {
Comment thread Dismissed
provider = CM_REPO_TYPE.GITLAB;
}

const pathBuilder = provider && this.#PR_PATH_BY_PROVIDER[provider];
if (!pathBuilder) {
return null;
}

const baseUrl = repoUrl.replace(/\.git$/, '');
return `${baseUrl}${pathBuilder(externalNumber)}`;
}

/**
* Creates a pull request in a CM repository via the CM Repo REST API.
* @param {string} programId - CM Program ID
Expand All @@ -565,10 +693,12 @@ export default class CloudManagerClient {
* @param {string} config.sourceBranch - Branch that contains the changes (head)
* @param {string} config.title - PR title
* @param {string} config.description - PR description
* @returns {Promise<Object>}
* @param {string} [config.repoUrl] - External repository URL (e.g. https://github.com/owner/repo.git)
* Used to construct the pullRequestUrl from the CM API response's externalNumber.
* @returns {Promise<Object>} CM API response augmented with pullRequestUrl
*/
async createPullRequest(programId, repositoryId, {
imsOrgId, destinationBranch, sourceBranch, title, description,
imsOrgId, destinationBranch, sourceBranch, title, description, repoUrl,
}) {
const { access_token: token } = await this.imsClient.getServiceAccessToken();
const url = `${this.config.cmRepoUrl}/api/program/${programId}/repository/${repositoryId}/pullRequests`;
Expand Down Expand Up @@ -596,6 +726,16 @@ export default class CloudManagerClient {
throw new Error(`Pull request creation failed: ${response.status} - ${errorText}`);
}

return response.json();
const result = await response.json();

// Construct pullRequestUrl from external repo URL and PR number
if (result.externalNumber && hasText(repoUrl)) {
const prUrl = this.#buildPullRequestUrl(repoUrl, result.externalNumber);
if (prUrl) {
result.pullRequestUrl = prUrl;
}
}

return result;
}
}
Loading
Loading