@adobe/spacecat-shared-cloud-manager-client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +230 -0
- package/package.json +53 -0
- package/src/index.d.ts +74 -0
- package/src/index.js +555 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
## @adobe/spacecat-shared-cloud-manager-client-v1.0.0 (2026-02-21)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* cloud manager client ([#1335](https://github.com/adobe/spacecat-shared/issues/1335)) ([2e4b013](https://github.com/adobe/spacecat-shared/commit/2e4b01359641706d633c16907b8292334581ce39))
|
package/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Spacecat Shared - Cloud Manager Client
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @adobe/spacecat-shared-cloud-manager-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
This client executes native git commands and requires the **git binary** available at `/opt/bin/git` (e.g. via an AWS Lambda Layer). The Lambda layer must provide:
|
|
16
|
+
|
|
17
|
+
- `git` binary at `/opt/bin/git`
|
|
18
|
+
- Git sub-commands (e.g. `git-remote-https`) at `/opt/libexec/git-core/`
|
|
19
|
+
- Shared libraries (`libcurl`, `libexpat`, etc.) at `/opt/lib/`
|
|
20
|
+
|
|
21
|
+
The client also uses the system `unzip` command (available on AWS Lambda AL2023 runtime) for `unzipRepository`.
|
|
22
|
+
|
|
23
|
+
The following environment variables are set automatically by the client for Lambda layer compatibility:
|
|
24
|
+
|
|
25
|
+
| Variable | Value |
|
|
26
|
+
|----------|-------|
|
|
27
|
+
| `PATH` | `/opt/bin:/usr/local/bin:/usr/bin:/bin` |
|
|
28
|
+
| `GIT_EXEC_PATH` | `/opt/libexec/git-core` |
|
|
29
|
+
| `LD_LIBRARY_PATH` | `/opt/lib:/lib64:/usr/lib64` |
|
|
30
|
+
| `GIT_TERMINAL_PROMPT` | `0` |
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Create a client from a Helix Universal context (e.g. in a Lambda with the required middleware):
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import CloudManagerClient from '@adobe/spacecat-shared-cloud-manager-client';
|
|
38
|
+
|
|
39
|
+
const client = CloudManagerClient.createFrom(context);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Required environment variables
|
|
43
|
+
|
|
44
|
+
| Variable | Description |
|
|
45
|
+
|----------|-------------|
|
|
46
|
+
| `IMS_HOST` | IMS host (e.g. `ims-na1.adobelogin.com`) |
|
|
47
|
+
| `ASO_CM_REPO_SERVICE_IMS_CLIENT_ID` | IMS client ID for the CM Repo Service |
|
|
48
|
+
| `ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET` | IMS client secret |
|
|
49
|
+
| `ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE` | IMS authorization code |
|
|
50
|
+
| `CM_REPO_URL` | Cloud Manager Repo API base URL (e.g. `https://cm-repo.example.com`) |
|
|
51
|
+
| `ASO_CODE_AUTOFIX_USERNAME` | Git committer username |
|
|
52
|
+
| `ASO_CODE_AUTOFIX_EMAIL` | Git committer email |
|
|
53
|
+
|
|
54
|
+
The context must also provide an S3 client via `context.s3.s3Client` (e.g. using `s3Wrapper`).
|
|
55
|
+
|
|
56
|
+
### Standard repository credentials (optional)
|
|
57
|
+
|
|
58
|
+
For **standard** (non-BYOG) Cloud Manager repositories, provide credentials via:
|
|
59
|
+
|
|
60
|
+
| Variable | Description |
|
|
61
|
+
|----------|-------------|
|
|
62
|
+
| `CM_STANDARD_REPO_CREDENTIALS` | JSON string mapping **program ID** to basic-auth credentials per program |
|
|
63
|
+
|
|
64
|
+
**Format:** A single JSON object. Each key is a Cloud Manager **program ID** (string). Each value is the HTTP basic-auth credential for that program in the form `"username:accessToken"`.
|
|
65
|
+
|
|
66
|
+
**Example:**
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"12345": "git-user:personal-access-token-or-password",
|
|
71
|
+
"67890": "another-user:another-token"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- If `CM_STANDARD_REPO_CREDENTIALS` is omitted or empty, only BYOG repos can be used (clone/pull/push require IMS Bearer token and `imsOrgId`).
|
|
76
|
+
- The value must be valid JSON; invalid JSON throws at `createFrom()`.
|
|
77
|
+
- For a given **programId**, credentials must exist in this map when using `repoType: 'standard'`; otherwise the client throws at clone/pull/push time.
|
|
78
|
+
|
|
79
|
+
## Repository types
|
|
80
|
+
|
|
81
|
+
The client supports two authentication modes:
|
|
82
|
+
|
|
83
|
+
| Type | Auth method | When to use |
|
|
84
|
+
|------|-------------|-------------|
|
|
85
|
+
| **BYOG** (Bring Your Own Git) | IMS Bearer token via `http.extraheader` | GitHub, GitLab, Bitbucket, Azure DevOps (or any `repoType` other than `'standard'`) |
|
|
86
|
+
| **Standard** | Base64 Basic auth via `http.extraheader` | Cloud Manager "standard" (managed) repos |
|
|
87
|
+
|
|
88
|
+
Both repo types authenticate via `http.extraheader` — no credentials are ever embedded in URLs. Use the same type for `clone`, `pull`, and `push` for a given repo (same `programId`/`repositoryId`).
|
|
89
|
+
|
|
90
|
+
**Security notes:**
|
|
91
|
+
|
|
92
|
+
- Clone directories are created via `mkdtempSync` under the OS temp directory, producing unique, unpredictable paths safe from symlink attacks and concurrent-run collisions.
|
|
93
|
+
- Patch files are also written into unique temp directories, cleaned up in a `finally` block.
|
|
94
|
+
- 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.
|
|
95
|
+
- All git commands run with a 120-second timeout to prevent hung processes from blocking the Lambda.
|
|
96
|
+
- `GIT_ASKPASS` is explicitly cleared to prevent inherited credential helpers from being invoked.
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
### BYOG repositories (e.g. GitHub, GitLab)
|
|
101
|
+
|
|
102
|
+
Clone and push use the IMS token and `imsOrgId`; no `CM_STANDARD_REPO_CREDENTIALS` needed.
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const programId = '12345';
|
|
106
|
+
const repositoryId = '67890';
|
|
107
|
+
const imsOrgId = 'your-ims-org@AdobeOrg';
|
|
108
|
+
|
|
109
|
+
// Clone (optionally checkout a specific ref)
|
|
110
|
+
const clonePath = await client.clone(programId, repositoryId, {
|
|
111
|
+
imsOrgId,
|
|
112
|
+
ref: 'release/5.11', // optional — checks out this ref after clone
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Pull latest changes (optional ref checks out the branch before pulling)
|
|
116
|
+
await client.pull(clonePath, programId, repositoryId, { imsOrgId, ref: 'main' });
|
|
117
|
+
|
|
118
|
+
// Checkout a specific ref (standalone, without pull)
|
|
119
|
+
await client.checkout(clonePath, 'main');
|
|
120
|
+
|
|
121
|
+
// Create a branch and apply a patch
|
|
122
|
+
await client.createBranch(clonePath, 'main', 'feature/fix');
|
|
123
|
+
|
|
124
|
+
// Apply a mail-message patch (git am — commit message is embedded in the patch)
|
|
125
|
+
await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.patch');
|
|
126
|
+
|
|
127
|
+
// Apply a plain diff patch (git apply — commitMessage is required)
|
|
128
|
+
await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.diff', {
|
|
129
|
+
commitMessage: 'Apply agent suggestion: fix accessibility issue',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Push (ref is required — specifies the branch to push)
|
|
133
|
+
await client.push(clonePath, programId, repositoryId, { imsOrgId, ref: 'feature/fix' });
|
|
134
|
+
|
|
135
|
+
// Create PR (BYOG only; uses IMS token)
|
|
136
|
+
const pr = await client.createPullRequest(programId, repositoryId, {
|
|
137
|
+
imsOrgId,
|
|
138
|
+
sourceBranch: 'feature/fix',
|
|
139
|
+
destinationBranch: 'main',
|
|
140
|
+
title: 'Fix issue',
|
|
141
|
+
description: 'Automated fix',
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Standard repositories
|
|
146
|
+
|
|
147
|
+
Use credentials from `CM_STANDARD_REPO_CREDENTIALS` and pass `repoType: 'standard'` and the repo URL.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const programId = '12345';
|
|
151
|
+
const repositoryId = '67890';
|
|
152
|
+
const imsOrgId = 'your-ims-org@AdobeOrg';
|
|
153
|
+
const repoUrl = 'https://git.cloudmanager.adobe.com/your-org/your-repo.git';
|
|
154
|
+
|
|
155
|
+
const config = {
|
|
156
|
+
imsOrgId,
|
|
157
|
+
repoType: 'standard',
|
|
158
|
+
repoUrl,
|
|
159
|
+
ref: 'main', // optional — checks out this ref after clone
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Clone
|
|
163
|
+
const clonePath = await client.clone(programId, repositoryId, config);
|
|
164
|
+
|
|
165
|
+
// Pull latest changes (optional ref checks out the branch before pulling)
|
|
166
|
+
await client.pull(clonePath, programId, repositoryId, { imsOrgId, repoType: 'standard', repoUrl, ref: 'main' });
|
|
167
|
+
|
|
168
|
+
// Create a branch and apply a patch
|
|
169
|
+
await client.createBranch(clonePath, 'main', 'feature/fix');
|
|
170
|
+
await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.patch', {
|
|
171
|
+
commitMessage: 'Apply agent suggestion',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Push (ref is required — specifies the branch to push)
|
|
175
|
+
await client.push(clonePath, programId, repositoryId, {
|
|
176
|
+
imsOrgId, repoType: 'standard', repoUrl, ref: 'feature/fix',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Zip the repository (includes .git history)
|
|
180
|
+
const zipBuffer = await client.zipRepository(clonePath);
|
|
181
|
+
|
|
182
|
+
// Cleanup
|
|
183
|
+
await client.cleanup(clonePath);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
*Note*: For Cloud Manager Standard Repositories, pull requests aren't supported, as there's no upstream.
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
## API overview
|
|
190
|
+
|
|
191
|
+
- **`clone(programId, repositoryId, config)`** – Clone repo to a unique temp directory. Config: `{ imsOrgId, repoType, repoUrl, ref }`. Optional `ref` checks out a specific branch/tag after clone (failure to checkout does not fail the clone).
|
|
192
|
+
- **`pull(clonePath, programId, repositoryId, config)`** – Pull latest changes into an existing clone. Config: `{ imsOrgId, repoType, repoUrl, ref }`. Optional `ref` checks out the branch before pulling.
|
|
193
|
+
- **`push(clonePath, programId, repositoryId, config)`** – Push a ref to the remote. Config: `{ imsOrgId, repoType, repoUrl, ref }`. The `ref` is **required** and specifies the branch to push.
|
|
194
|
+
- **`checkout(clonePath, ref)`** – Checkout a specific git ref (branch, tag, or SHA) in an existing clone. Unlike the optional checkout in `clone()`, this throws on failure.
|
|
195
|
+
- **`zipRepository(clonePath)`** – Zip the clone (including `.git` history) and return a Buffer.
|
|
196
|
+
- **`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.
|
|
197
|
+
- **`createBranch(clonePath, baseBranch, newBranch)`** – Checkout the base branch and create a new branch from it.
|
|
198
|
+
- **`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.
|
|
199
|
+
- **`cleanup(clonePath)`** – Remove the clone directory. Validates the path starts with the expected temp prefix to prevent accidental deletion.
|
|
200
|
+
- **`createPullRequest(programId, repositoryId, config)`** – Create a PR via the CM Repo API (BYOG only, uses IMS token). Config: `{ imsOrgId, sourceBranch, destinationBranch, title, description }`.
|
|
201
|
+
|
|
202
|
+
## Exports
|
|
203
|
+
|
|
204
|
+
Repository type constants (for use when passing `repoType` or checking repo type):
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
import CloudManagerClient, { CM_REPO_TYPE } from '@adobe/spacecat-shared-cloud-manager-client';
|
|
208
|
+
|
|
209
|
+
// CM_REPO_TYPE.GITHUB → 'github'
|
|
210
|
+
// CM_REPO_TYPE.BITBUCKET → 'bitbucket'
|
|
211
|
+
// CM_REPO_TYPE.GITLAB → 'gitlab'
|
|
212
|
+
// CM_REPO_TYPE.AZURE_DEVOPS → 'azure_devops'
|
|
213
|
+
// CM_REPO_TYPE.STANDARD → 'standard'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Testing
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npm run test
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Linting
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npm run lint
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Apache-2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adobe/spacecat-shared-cloud-manager-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared modules of the Spacecat Services - Cloud Manager Client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0 <25.0.0",
|
|
8
|
+
"npm": ">=10.9.0 <12.0.0"
|
|
9
|
+
},
|
|
10
|
+
"main": "src/index.js",
|
|
11
|
+
"types": "src/index.d.ts",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "c8 mocha",
|
|
14
|
+
"lint": "eslint .",
|
|
15
|
+
"clean": "rm -rf package-lock.json node_modules"
|
|
16
|
+
},
|
|
17
|
+
"mocha": {
|
|
18
|
+
"require": "test/setup-env.js",
|
|
19
|
+
"reporter": "mocha-multi-reporters",
|
|
20
|
+
"reporter-options": "configFile=.mocha-multi.json",
|
|
21
|
+
"spec": "test/**/*.test.js"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/adobe/spacecat-shared.git"
|
|
26
|
+
},
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/adobe/spacecat-shared/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/adobe/spacecat-shared#readme",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@adobe/fetch": "4.2.3",
|
|
38
|
+
"@adobe/spacecat-shared-ims-client": "1.11.6",
|
|
39
|
+
"@adobe/spacecat-shared-utils": "1.81.1",
|
|
40
|
+
"@aws-sdk/client-s3": "3.940.0",
|
|
41
|
+
"adm-zip": "0.5.16"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"aws-sdk-client-mock": "4.1.0",
|
|
45
|
+
"chai": "6.2.1",
|
|
46
|
+
"chai-as-promised": "8.0.2",
|
|
47
|
+
"nock": "14.0.10",
|
|
48
|
+
"sinon": "21.0.0",
|
|
49
|
+
"esmock": "2.7.3",
|
|
50
|
+
"sinon-chai": "4.0.1",
|
|
51
|
+
"typescript": "5.9.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { UniversalContext } from '@adobe/helix-universal';
|
|
14
|
+
|
|
15
|
+
export declare const CM_REPO_TYPE: Readonly<{
|
|
16
|
+
GITHUB: 'github';
|
|
17
|
+
BITBUCKET: 'bitbucket';
|
|
18
|
+
GITLAB: 'gitlab';
|
|
19
|
+
AZURE_DEVOPS: 'azure_devops';
|
|
20
|
+
STANDARD: 'standard';
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
export interface CloneConfig {
|
|
24
|
+
imsOrgId: string;
|
|
25
|
+
repoType: string;
|
|
26
|
+
repoUrl: string;
|
|
27
|
+
ref?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PushConfig {
|
|
31
|
+
imsOrgId: string;
|
|
32
|
+
repoType: string;
|
|
33
|
+
repoUrl: string;
|
|
34
|
+
ref: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PullConfig {
|
|
38
|
+
imsOrgId: string;
|
|
39
|
+
repoType: string;
|
|
40
|
+
repoUrl: string;
|
|
41
|
+
ref?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PullRequestConfig {
|
|
45
|
+
imsOrgId: string;
|
|
46
|
+
destinationBranch: string;
|
|
47
|
+
sourceBranch: string;
|
|
48
|
+
title: string;
|
|
49
|
+
description: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default class CloudManagerClient {
|
|
53
|
+
static createFrom(context: UniversalContext): CloudManagerClient;
|
|
54
|
+
|
|
55
|
+
clone(programId: string, repositoryId: string, config: CloneConfig): Promise<string>;
|
|
56
|
+
push(clonePath: string, programId: string, repositoryId: string, config: PushConfig): Promise<void>;
|
|
57
|
+
pull(clonePath: string, programId: string, repositoryId: string, config: PullConfig): Promise<void>;
|
|
58
|
+
checkout(clonePath: string, ref: string): Promise<void>;
|
|
59
|
+
unzipRepository(zipBuffer: Buffer): Promise<string>;
|
|
60
|
+
zipRepository(clonePath: string): Promise<Buffer>;
|
|
61
|
+
createBranch(clonePath: string, baseBranch: string, newBranch: string): Promise<void>;
|
|
62
|
+
applyPatch(
|
|
63
|
+
clonePath: string,
|
|
64
|
+
branch: string,
|
|
65
|
+
s3PatchPath: string,
|
|
66
|
+
options?: { commitMessage?: string },
|
|
67
|
+
): Promise<void>;
|
|
68
|
+
cleanup(clonePath: string): Promise<void>;
|
|
69
|
+
createPullRequest(
|
|
70
|
+
programId: string,
|
|
71
|
+
repositoryId: string,
|
|
72
|
+
config: PullRequestConfig,
|
|
73
|
+
): Promise<object>;
|
|
74
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
|
+
import {
|
|
15
|
+
existsSync, mkdtempSync, rmSync, statfsSync, writeFileSync,
|
|
16
|
+
} from 'fs';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { hasText, tracingFetch as fetch } from '@adobe/spacecat-shared-utils';
|
|
20
|
+
import { ImsClient } from '@adobe/spacecat-shared-ims-client';
|
|
21
|
+
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
|
22
|
+
import AdmZip from 'adm-zip';
|
|
23
|
+
|
|
24
|
+
const GIT_BIN = process.env.GIT_BIN_PATH || '/opt/bin/git';
|
|
25
|
+
const CLONE_DIR_PREFIX = 'cm-repo-';
|
|
26
|
+
const PATCH_FILE_PREFIX = 'cm-patch-';
|
|
27
|
+
const GIT_OPERATION_TIMEOUT_MS = 120_000; // 120s — fail fast before Lambda timeout
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Repository type constants for Cloud Manager integrations.
|
|
31
|
+
* Standard repos use basic auth; all others (BYOG) use Bearer token extraheaders.
|
|
32
|
+
*/
|
|
33
|
+
export const CM_REPO_TYPE = Object.freeze({
|
|
34
|
+
GITHUB: 'github',
|
|
35
|
+
BITBUCKET: 'bitbucket',
|
|
36
|
+
GITLAB: 'gitlab',
|
|
37
|
+
AZURE_DEVOPS: 'azure_devops',
|
|
38
|
+
STANDARD: 'standard',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Lambda layer environment: git and its helpers (git-remote-https) live under /opt.
|
|
42
|
+
// Without these, the dynamic linker can't find shared libraries (libcurl, libexpat, …)
|
|
43
|
+
// and git can't locate its sub-commands (git-remote-https for HTTPS transport).
|
|
44
|
+
const GIT_ENV = {
|
|
45
|
+
...process.env,
|
|
46
|
+
PATH: '/opt/bin:/usr/local/bin:/usr/bin:/bin',
|
|
47
|
+
GIT_EXEC_PATH: '/opt/libexec/git-core',
|
|
48
|
+
LD_LIBRARY_PATH: '/opt/lib:/lib64:/usr/lib64',
|
|
49
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
50
|
+
GIT_ASKPASS: '',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parses an S3 URI (s3://bucket/key) into bucket and key.
|
|
55
|
+
* @param {string} s3Path - S3 URI
|
|
56
|
+
* @returns {{ bucket: string, key: string }}
|
|
57
|
+
*/
|
|
58
|
+
function parseS3Path(s3Path) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = new URL(s3Path);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`Invalid S3 path: ${s3Path}. Expected format: s3://bucket/key`);
|
|
64
|
+
}
|
|
65
|
+
if (parsed.protocol !== 's3:' || !parsed.hostname || !parsed.pathname.slice(1)) {
|
|
66
|
+
throw new Error(`Invalid S3 path: ${s3Path}. Expected format: s3://bucket/key`);
|
|
67
|
+
}
|
|
68
|
+
return { bucket: parsed.hostname, key: parsed.pathname.slice(1) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default class CloudManagerClient {
|
|
72
|
+
/**
|
|
73
|
+
* Creates a CloudManagerClient from a Universal context.
|
|
74
|
+
* @param {Object} context - Universal function context
|
|
75
|
+
* @returns {CloudManagerClient}
|
|
76
|
+
*/
|
|
77
|
+
static createFrom(context) {
|
|
78
|
+
const { log = console } = context;
|
|
79
|
+
const {
|
|
80
|
+
IMS_HOST: imsHost,
|
|
81
|
+
ASO_CM_REPO_SERVICE_IMS_CLIENT_ID: clientId,
|
|
82
|
+
ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET: clientSecret,
|
|
83
|
+
ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE: clientCode,
|
|
84
|
+
CM_REPO_URL: cmRepoUrl,
|
|
85
|
+
CM_STANDARD_REPO_CREDENTIALS: standardRepoCredentialsRaw,
|
|
86
|
+
ASO_CODE_AUTOFIX_USERNAME: gitUsername,
|
|
87
|
+
ASO_CODE_AUTOFIX_EMAIL: gitUserEmail,
|
|
88
|
+
} = context.env;
|
|
89
|
+
|
|
90
|
+
const s3Client = context.s3?.s3Client;
|
|
91
|
+
if (!s3Client) {
|
|
92
|
+
throw new Error('CloudManagerClient requires S3 client. Ensure s3Wrapper is configured.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!hasText(imsHost) || !hasText(clientId) || !hasText(clientSecret) || !hasText(clientCode)) {
|
|
96
|
+
throw new Error('CloudManagerClient requires IMS_HOST, ASO_CM_REPO_SERVICE_IMS_CLIENT_ID,'
|
|
97
|
+
+ ' ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET, and ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!hasText(cmRepoUrl)) {
|
|
101
|
+
throw new Error('CloudManagerClient requires CM_REPO_URL.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!hasText(gitUsername) || !hasText(gitUserEmail)) {
|
|
105
|
+
throw new Error('CloudManagerClient requires ASO_CODE_AUTOFIX_USERNAME and ASO_CODE_AUTOFIX_EMAIL.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Optional: credentials for standard (non-BYOG) CM repos.
|
|
109
|
+
// JSON object mapping programId to "username:accessToken".
|
|
110
|
+
let standardRepoCredentials = {};
|
|
111
|
+
if (hasText(standardRepoCredentialsRaw)) {
|
|
112
|
+
try {
|
|
113
|
+
standardRepoCredentials = JSON.parse(standardRepoCredentialsRaw);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
throw new Error('CM_STANDARD_REPO_CREDENTIALS must be valid JSON.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Create ImsClient for token management (handles caching internally)
|
|
120
|
+
const imsClient = ImsClient.createFrom({
|
|
121
|
+
env: {
|
|
122
|
+
IMS_HOST: imsHost,
|
|
123
|
+
IMS_CLIENT_ID: clientId,
|
|
124
|
+
IMS_CLIENT_CODE: clientCode,
|
|
125
|
+
IMS_CLIENT_SECRET: clientSecret,
|
|
126
|
+
},
|
|
127
|
+
log,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return new CloudManagerClient({
|
|
131
|
+
clientId,
|
|
132
|
+
cmRepoUrl,
|
|
133
|
+
standardRepoCredentials,
|
|
134
|
+
gitUsername,
|
|
135
|
+
gitUserEmail,
|
|
136
|
+
}, imsClient, s3Client, log);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
constructor(config, imsClient, s3Client, log = console) {
|
|
140
|
+
this.config = config;
|
|
141
|
+
this.imsClient = imsClient;
|
|
142
|
+
this.s3Client = s3Client;
|
|
143
|
+
this.log = log;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Private helpers ---
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Logs /tmp disk usage (total, used, free) for capacity monitoring.
|
|
150
|
+
* Uses statfsSync — a single syscall with negligible cost.
|
|
151
|
+
* @param {string} operation - The operation that just completed (e.g. 'clone', 'pull')
|
|
152
|
+
*/
|
|
153
|
+
#logTmpDiskUsage(operation) {
|
|
154
|
+
const { bsize, blocks, bfree } = statfsSync(os.tmpdir());
|
|
155
|
+
const totalMB = Math.round((bsize * blocks) / (1024 * 1024));
|
|
156
|
+
const freeMB = Math.round((bsize * bfree) / (1024 * 1024));
|
|
157
|
+
const usedMB = totalMB - freeMB;
|
|
158
|
+
this.log.info(`[${operation}] /tmp disk usage: ${usedMB} MB used, ${freeMB} MB free, ${totalMB} MB total`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Looks up basic-auth credentials for a standard (non-BYOG) CM repository.
|
|
163
|
+
* @param {string} programId - CM Program ID
|
|
164
|
+
* @returns {string} "username:accessToken"
|
|
165
|
+
*/
|
|
166
|
+
#getStandardRepoCredentials(programId) {
|
|
167
|
+
const credentials = this.config.standardRepoCredentials[programId];
|
|
168
|
+
if (!credentials) {
|
|
169
|
+
throw new Error(`No standard repo credentials found for programId: ${programId}. Check CM_STANDARD_REPO_CREDENTIALS.`);
|
|
170
|
+
}
|
|
171
|
+
return credentials;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Builds the git -c http.extraheader arguments for CM repo auth.
|
|
176
|
+
* @param {string} imsOrgId - IMS Organization ID
|
|
177
|
+
* @returns {Promise<string[]>} Array of git config args
|
|
178
|
+
*/
|
|
179
|
+
async #getCMRepoServiceCredentials(imsOrgId) {
|
|
180
|
+
const { access_token: token } = await this.imsClient.getServiceAccessToken();
|
|
181
|
+
const { cmRepoUrl, clientId } = this.config;
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
'-c', `http.${cmRepoUrl}.extraheader=Authorization: Bearer ${token}`,
|
|
185
|
+
'-c', `http.${cmRepoUrl}.extraheader=x-api-key: ${clientId}`,
|
|
186
|
+
'-c', `http.${cmRepoUrl}.extraheader=x-gw-ims-org-id: ${imsOrgId}`,
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Constructs the git repository URL for a CM repo.
|
|
192
|
+
* @param {string} programId
|
|
193
|
+
* @param {string} repositoryId
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
#getRepoUrl(programId, repositoryId) {
|
|
197
|
+
return `${this.config.cmRepoUrl}/api/program/${programId}/repository/${repositoryId}.git`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Executes a git command.
|
|
202
|
+
* @param {string[]} args - Arguments to pass to git
|
|
203
|
+
* @param {Object} [options] - execFileSync options
|
|
204
|
+
* @returns {string} stdout
|
|
205
|
+
*/
|
|
206
|
+
#execGit(args, options = {}) {
|
|
207
|
+
try {
|
|
208
|
+
return execFileSync(GIT_BIN, args, {
|
|
209
|
+
encoding: 'utf-8', env: GIT_ENV, timeout: GIT_OPERATION_TIMEOUT_MS, ...options,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.killed) {
|
|
213
|
+
this.log.error(`Git command timed out after ${GIT_OPERATION_TIMEOUT_MS / 1000}s`);
|
|
214
|
+
throw new Error(`Git command timed out after ${GIT_OPERATION_TIMEOUT_MS / 1000}s`);
|
|
215
|
+
}
|
|
216
|
+
// Sanitize tokens and credentials from all error output sources
|
|
217
|
+
const rawMessage = [error.stderr, error.stdout, error.message]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join('\n');
|
|
220
|
+
const sanitized = rawMessage
|
|
221
|
+
.replace(/Bearer [^\s"]+/g, 'Bearer [REDACTED]')
|
|
222
|
+
.replace(/:\/\/[^@]+@/g, '://***@')
|
|
223
|
+
.replace(/x-api-key: [^\s"]+/g, 'x-api-key: [REDACTED]')
|
|
224
|
+
.replace(/x-gw-ims-org-id: [^\s"]+/g, 'x-gw-ims-org-id: [REDACTED]')
|
|
225
|
+
.replace(/Authorization: Basic [^\s"]+/g, 'Authorization: Basic [REDACTED]');
|
|
226
|
+
this.log.error(`Git command failed: ${sanitized}`);
|
|
227
|
+
throw new Error(`Git command failed: ${sanitized}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Builds authenticated git arguments for a remote command (clone, push, or pull).
|
|
233
|
+
*
|
|
234
|
+
* Both repo types use http.extraheader for authentication:
|
|
235
|
+
* - Standard repos: Basic auth header via extraheader on the repo URL
|
|
236
|
+
* - BYOG repos: Bearer token + API key + IMS org ID via extraheader on the CM Repo URL
|
|
237
|
+
*
|
|
238
|
+
* @param {string} command - The git command ('clone', 'push', or 'pull')
|
|
239
|
+
* @param {string} programId - CM Program ID
|
|
240
|
+
* @param {string} repositoryId - CM Repository ID
|
|
241
|
+
* @param {Object} config - Repository auth configuration
|
|
242
|
+
* @param {string} config.imsOrgId - IMS Organization ID
|
|
243
|
+
* @param {string} config.repoType - Repository type ('standard' or VCS type)
|
|
244
|
+
* @param {string} config.repoUrl - Repository URL
|
|
245
|
+
* @returns {Promise<string[]>} Array of git arguments
|
|
246
|
+
*/
|
|
247
|
+
async #buildAuthGitArgs(command, programId, repositoryId, { imsOrgId, repoType, repoUrl } = {}) {
|
|
248
|
+
if (repoType === CM_REPO_TYPE.STANDARD) {
|
|
249
|
+
const credentials = this.#getStandardRepoCredentials(programId);
|
|
250
|
+
const basicAuth = Buffer.from(credentials).toString('base64');
|
|
251
|
+
return [
|
|
252
|
+
'-c', `http.${repoUrl}.extraheader=Authorization: Basic ${basicAuth}`,
|
|
253
|
+
command, repoUrl,
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const cmRepoServiceCredentials = await this.#getCMRepoServiceCredentials(imsOrgId);
|
|
258
|
+
const byogRepoUrl = this.#getRepoUrl(programId, repositoryId);
|
|
259
|
+
return [...cmRepoServiceCredentials, command, byogRepoUrl];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Clones a Cloud Manager repository to /tmp.
|
|
264
|
+
*
|
|
265
|
+
* Both repo types authenticate via http.extraheader (no credentials in the URL):
|
|
266
|
+
* - BYOG repos: Bearer token + API key + IMS org ID via CM Repo URL
|
|
267
|
+
* - Standard repos: Basic auth header via the repo URL
|
|
268
|
+
*
|
|
269
|
+
* If a ref is provided, the clone will be checked out to that ref after cloning.
|
|
270
|
+
* Checkout failures are logged but do not cause the clone to fail, so the caller
|
|
271
|
+
* always gets a usable working copy (on the default branch if checkout fails).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} programId - CM Program ID
|
|
274
|
+
* @param {string} repositoryId - CM Repository ID
|
|
275
|
+
* @param {Object} config - Clone configuration
|
|
276
|
+
* @param {string} config.imsOrgId - IMS Organization ID
|
|
277
|
+
* @param {string} config.repoType - Repository type ('standard' or VCS type)
|
|
278
|
+
* @param {string} config.repoUrl - Repository URL
|
|
279
|
+
* @param {string} [config.ref] - Optional. Git ref to checkout after clone (branch, tag, or SHA)
|
|
280
|
+
* @returns {Promise<string>} The local clone path
|
|
281
|
+
*/
|
|
282
|
+
async clone(programId, repositoryId, {
|
|
283
|
+
imsOrgId, repoType, repoUrl, ref,
|
|
284
|
+
} = {}) {
|
|
285
|
+
const clonePath = mkdtempSync(path.join(os.tmpdir(), CLONE_DIR_PREFIX));
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
this.log.info(`Cloning CM repository: program=${programId}, repo=${repositoryId}, type=${repoType}`);
|
|
289
|
+
|
|
290
|
+
const args = await this.#buildAuthGitArgs('clone', programId, repositoryId, { imsOrgId, repoType, repoUrl });
|
|
291
|
+
this.#execGit([...args, clonePath]);
|
|
292
|
+
this.log.info(`Repository cloned to ${clonePath}`);
|
|
293
|
+
this.#logTmpDiskUsage('clone');
|
|
294
|
+
|
|
295
|
+
// Checkout the requested ref if provided
|
|
296
|
+
if (hasText(ref)) {
|
|
297
|
+
try {
|
|
298
|
+
this.#execGit(['checkout', ref], { cwd: clonePath });
|
|
299
|
+
this.log.info(`Checked out ref '${ref}' in ${clonePath}`);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.log.error(`Failed to checkout ref '${ref}': ${error.message}. Continuing with default branch.`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return clonePath;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
rmSync(clonePath, { recursive: true, force: true });
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Zips the entire cloned repository including .git history.
|
|
314
|
+
* Downstream services that read the ZIP from S3 need the full git history.
|
|
315
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
316
|
+
* @returns {Promise<Buffer>} ZIP file as a Buffer
|
|
317
|
+
*/
|
|
318
|
+
async zipRepository(clonePath) {
|
|
319
|
+
if (!existsSync(clonePath)) {
|
|
320
|
+
throw new Error(`Clone path does not exist: ${clonePath}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.log.info(`Zipping repository at ${clonePath}`);
|
|
324
|
+
const zip = new AdmZip();
|
|
325
|
+
zip.addLocalFolder(clonePath);
|
|
326
|
+
return zip.toBuffer();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Creates a new branch from a base branch.
|
|
331
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
332
|
+
* @param {string} baseBranch - Base branch name
|
|
333
|
+
* @param {string} newBranch - New branch name
|
|
334
|
+
*/
|
|
335
|
+
async createBranch(clonePath, baseBranch, newBranch) {
|
|
336
|
+
this.log.info(`Creating branch ${newBranch} from ${baseBranch}`);
|
|
337
|
+
this.#execGit(['checkout', baseBranch], { cwd: clonePath });
|
|
338
|
+
this.#execGit(['checkout', '-b', newBranch], { cwd: clonePath });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Downloads a patch from S3 and applies it to the given branch.
|
|
343
|
+
* Supports two patch formats, detected automatically from the content:
|
|
344
|
+
* - Mail-message format (starts with "From "): applied via git am, which
|
|
345
|
+
* creates the commit using embedded metadata (author, date, message).
|
|
346
|
+
* - Plain diff format (starts with "diff "): applied via git apply, then
|
|
347
|
+
* staged and committed. A commitMessage option is required for this flow.
|
|
348
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
349
|
+
* @param {string} branch - Branch to apply the patch on
|
|
350
|
+
* @param {string} s3PatchPath - S3 URI of the patch file (s3://bucket/key)
|
|
351
|
+
* @param {object} [options] - Optional settings
|
|
352
|
+
* @param {string} [options.commitMessage] - Commit message for plain diff patches. Required
|
|
353
|
+
* when the patch is a plain diff. Ignored for mail-message patches (git am uses the
|
|
354
|
+
* embedded commit message); a warning is logged if provided with a mail-message patch.
|
|
355
|
+
*/
|
|
356
|
+
async applyPatch(clonePath, branch, s3PatchPath, options = {}) {
|
|
357
|
+
const { bucket, key } = parseS3Path(s3PatchPath);
|
|
358
|
+
const patchDir = mkdtempSync(path.join(os.tmpdir(), PATCH_FILE_PREFIX));
|
|
359
|
+
const patchFile = path.join(patchDir, 'applied.patch');
|
|
360
|
+
const { gitUsername, gitUserEmail } = this.config;
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
// Download patch from S3
|
|
364
|
+
this.log.info(`Downloading patch from s3://${bucket}/${key}`);
|
|
365
|
+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
366
|
+
const response = await this.s3Client.send(command);
|
|
367
|
+
const patchContent = await response.Body.transformToString();
|
|
368
|
+
writeFileSync(patchFile, patchContent);
|
|
369
|
+
|
|
370
|
+
// Configure committer identity
|
|
371
|
+
this.#execGit(['config', 'user.name', gitUsername], { cwd: clonePath });
|
|
372
|
+
this.#execGit(['config', 'user.email', gitUserEmail], { cwd: clonePath });
|
|
373
|
+
|
|
374
|
+
// Checkout branch
|
|
375
|
+
this.#execGit(['checkout', branch], { cwd: clonePath });
|
|
376
|
+
|
|
377
|
+
// Detect format from content and apply accordingly
|
|
378
|
+
const isMailMessage = patchContent.startsWith('From ');
|
|
379
|
+
const { commitMessage } = options;
|
|
380
|
+
|
|
381
|
+
if (isMailMessage) {
|
|
382
|
+
// Mail-message format: git am creates the commit using embedded metadata
|
|
383
|
+
if (commitMessage) {
|
|
384
|
+
this.log.warn('commitMessage is ignored for mail-message patches; git am uses the embedded commit message');
|
|
385
|
+
}
|
|
386
|
+
this.#execGit(['am', patchFile], { cwd: clonePath });
|
|
387
|
+
} else {
|
|
388
|
+
// Plain diff format: apply, stage, and commit
|
|
389
|
+
if (!commitMessage) {
|
|
390
|
+
throw new Error('commitMessage is required when applying a plain diff patch');
|
|
391
|
+
}
|
|
392
|
+
this.#execGit(['apply', patchFile], { cwd: clonePath });
|
|
393
|
+
this.#execGit(['add', '-A'], { cwd: clonePath });
|
|
394
|
+
this.#execGit(['commit', '-m', commitMessage], { cwd: clonePath });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
this.log.info(`Patch applied and committed on branch ${branch}`);
|
|
398
|
+
} finally {
|
|
399
|
+
// Clean up temp patch directory and file
|
|
400
|
+
if (existsSync(patchDir)) {
|
|
401
|
+
rmSync(patchDir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Pushes the current branch to the remote CM repository.
|
|
408
|
+
* Commits are expected to already exist (e.g. via applyPatch).
|
|
409
|
+
*
|
|
410
|
+
* For BYOG repos: uses Bearer token + API key + IMS org ID via extraheader.
|
|
411
|
+
* For standard repos: uses Basic auth via extraheader.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
414
|
+
* @param {string} programId - CM Program ID
|
|
415
|
+
* @param {string} repositoryId - CM Repository ID
|
|
416
|
+
* @param {Object} config - Push configuration
|
|
417
|
+
* @param {string} config.imsOrgId - IMS Organization ID
|
|
418
|
+
* @param {string} config.repoType - Repository type ('standard' or VCS type)
|
|
419
|
+
* @param {string} config.repoUrl - Repository URL
|
|
420
|
+
* @param {string} config.ref - Git ref (branch) to push
|
|
421
|
+
*/
|
|
422
|
+
async push(clonePath, programId, repositoryId, {
|
|
423
|
+
imsOrgId, repoType, repoUrl, ref,
|
|
424
|
+
} = {}) {
|
|
425
|
+
const pushArgs = await this.#buildAuthGitArgs('push', programId, repositoryId, { imsOrgId, repoType, repoUrl });
|
|
426
|
+
pushArgs.push(ref);
|
|
427
|
+
this.#execGit(pushArgs, { cwd: clonePath });
|
|
428
|
+
this.log.info('Changes pushed successfully');
|
|
429
|
+
this.#logTmpDiskUsage('push');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Pulls the latest changes from the remote CM repository into an existing clone.
|
|
434
|
+
*
|
|
435
|
+
* For BYOG repos: uses Bearer token + API key + IMS org ID via extraheader.
|
|
436
|
+
* For standard repos: uses Basic auth via extraheader.
|
|
437
|
+
*
|
|
438
|
+
* If a ref is provided, the ref is checked out before pulling so that
|
|
439
|
+
* the pull updates the correct branch.
|
|
440
|
+
*
|
|
441
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
442
|
+
* @param {string} programId - CM Program ID
|
|
443
|
+
* @param {string} repositoryId - CM Repository ID
|
|
444
|
+
* @param {Object} config - Pull configuration
|
|
445
|
+
* @param {string} config.imsOrgId - IMS Organization ID
|
|
446
|
+
* @param {string} config.repoType - Repository type ('standard' or VCS type)
|
|
447
|
+
* @param {string} config.repoUrl - Repository URL
|
|
448
|
+
* @param {string} [config.ref] - Optional. Git ref to checkout before pull (branch, tag, or SHA)
|
|
449
|
+
*/
|
|
450
|
+
async pull(clonePath, programId, repositoryId, {
|
|
451
|
+
imsOrgId, repoType, repoUrl, ref,
|
|
452
|
+
} = {}) {
|
|
453
|
+
if (hasText(ref)) {
|
|
454
|
+
this.#execGit(['checkout', ref], { cwd: clonePath });
|
|
455
|
+
this.log.info(`Checked out ref '${ref}' before pull`);
|
|
456
|
+
}
|
|
457
|
+
const pullArgs = await this.#buildAuthGitArgs('pull', programId, repositoryId, { imsOrgId, repoType, repoUrl });
|
|
458
|
+
this.#execGit(pullArgs, { cwd: clonePath });
|
|
459
|
+
this.log.info('Changes pulled successfully');
|
|
460
|
+
this.#logTmpDiskUsage('pull');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Checks out a specific git ref (branch, tag, or SHA) in an existing repository.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} clonePath - Path to the cloned repository
|
|
467
|
+
* @param {string} ref - Git ref to checkout (branch, tag, or SHA)
|
|
468
|
+
*/
|
|
469
|
+
async checkout(clonePath, ref) {
|
|
470
|
+
this.log.info(`Checking out ref '${ref}' in ${clonePath}`);
|
|
471
|
+
this.#execGit(['checkout', ref], { cwd: clonePath });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Extracts a ZIP buffer into a new unique temp directory.
|
|
476
|
+
* Used to restore a previously-zipped repository from S3
|
|
477
|
+
* for incremental updates (checkout + pull) instead of a full clone.
|
|
478
|
+
*
|
|
479
|
+
* @param {Buffer} zipBuffer - ZIP file content as a Buffer
|
|
480
|
+
* @returns {Promise<string>} Path to the extracted repository
|
|
481
|
+
*/
|
|
482
|
+
async unzipRepository(zipBuffer) {
|
|
483
|
+
const extractPath = mkdtempSync(path.join(os.tmpdir(), CLONE_DIR_PREFIX));
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const zip = new AdmZip(zipBuffer);
|
|
487
|
+
zip.extractAllTo(extractPath, true);
|
|
488
|
+
this.log.info(`Repository extracted to ${extractPath}`);
|
|
489
|
+
return extractPath;
|
|
490
|
+
} catch (error) {
|
|
491
|
+
rmSync(extractPath, { recursive: true, force: true });
|
|
492
|
+
throw new Error(`Failed to unzip repository: ${error.message}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Removes a cloned repository from the temp directory.
|
|
498
|
+
* @param {string} clonePath - Path to remove
|
|
499
|
+
*/
|
|
500
|
+
async cleanup(clonePath) {
|
|
501
|
+
const expectedPrefix = path.join(os.tmpdir(), CLONE_DIR_PREFIX);
|
|
502
|
+
if (!clonePath || !clonePath.startsWith(expectedPrefix)) {
|
|
503
|
+
throw new Error(`Invalid clone path for cleanup: ${clonePath}. Must be a cm-repo temp directory.`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (existsSync(clonePath)) {
|
|
507
|
+
rmSync(clonePath, { recursive: true, force: true });
|
|
508
|
+
this.log.info(`Cleaned up ${clonePath}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Creates a pull request in a CM repository via the CM Repo REST API.
|
|
514
|
+
* @param {string} programId - CM Program ID
|
|
515
|
+
* @param {string} repositoryId - CM Repository ID
|
|
516
|
+
* @param {Object} config - PR configuration
|
|
517
|
+
* @param {string} config.imsOrgId - IMS Organization ID
|
|
518
|
+
* @param {string} config.destinationBranch - Branch to merge into (base)
|
|
519
|
+
* @param {string} config.sourceBranch - Branch that contains the changes (head)
|
|
520
|
+
* @param {string} config.title - PR title
|
|
521
|
+
* @param {string} config.description - PR description
|
|
522
|
+
* @returns {Promise<Object>}
|
|
523
|
+
*/
|
|
524
|
+
async createPullRequest(programId, repositoryId, {
|
|
525
|
+
imsOrgId, destinationBranch, sourceBranch, title, description,
|
|
526
|
+
}) {
|
|
527
|
+
const { access_token: token } = await this.imsClient.getServiceAccessToken();
|
|
528
|
+
const url = `${this.config.cmRepoUrl}/api/program/${programId}/repository/${repositoryId}/pullRequests`;
|
|
529
|
+
|
|
530
|
+
this.log.info(`Creating PR for program=${programId}, repo=${repositoryId}: ${title}`);
|
|
531
|
+
|
|
532
|
+
const response = await fetch(url, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: {
|
|
535
|
+
'Content-Type': 'application/json',
|
|
536
|
+
Authorization: `Bearer ${token}`,
|
|
537
|
+
'x-api-key': this.config.clientId,
|
|
538
|
+
'x-gw-ims-org-id': imsOrgId,
|
|
539
|
+
},
|
|
540
|
+
body: JSON.stringify({
|
|
541
|
+
title,
|
|
542
|
+
sourceBranch,
|
|
543
|
+
destinationBranch,
|
|
544
|
+
description,
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
const errorText = await response.text();
|
|
550
|
+
throw new Error(`Pull request creation failed: ${response.status} - ${errorText}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return response.json();
|
|
554
|
+
}
|
|
555
|
+
}
|