@pagopa/dx-cli 0.16.3 → 0.18.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/dist/adapters/azure/__tests__/cloud-account-service.test.js +35 -0
- package/dist/adapters/azure/cloud-account-service.d.ts +2 -1
- package/dist/adapters/azure/cloud-account-service.js +74 -14
- package/dist/adapters/commander/commands/add.d.ts +11 -0
- package/dist/adapters/commander/commands/add.js +27 -0
- package/dist/adapters/commander/commands/init.d.ts +2 -0
- package/dist/adapters/commander/commands/init.js +1 -1
- package/dist/adapters/commander/index.js +2 -0
- package/dist/adapters/octokit/__tests__/index.test.js +218 -1
- package/dist/adapters/octokit/index.d.ts +4 -1
- package/dist/adapters/octokit/index.js +65 -1
- package/dist/adapters/pagopa-technology/__tests__/authorization.test.d.ts +4 -0
- package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +170 -0
- package/dist/adapters/pagopa-technology/authorization.d.ts +11 -0
- package/dist/adapters/pagopa-technology/authorization.js +104 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +35 -3
- package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +15 -0
- package/dist/adapters/plop/actions/init-cloud-accounts.js +5 -2
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +5 -0
- package/dist/adapters/plop/generators/environment/prompts.d.ts +14 -0
- package/dist/adapters/plop/generators/environment/prompts.js +67 -31
- package/dist/adapters/plop/index.d.ts +18 -2
- package/dist/adapters/plop/index.js +16 -0
- package/dist/domain/__tests__/data.d.ts +2 -0
- package/dist/domain/__tests__/data.js +1 -0
- package/dist/domain/authorization.d.ts +49 -0
- package/dist/domain/authorization.js +73 -0
- package/dist/domain/cloud-account.d.ts +2 -1
- package/dist/domain/dependencies.d.ts +2 -0
- package/dist/domain/github.d.ts +51 -0
- package/dist/domain/github.js +12 -0
- package/dist/index.js +5 -0
- package/dist/use-cases/__tests__/request-authorization.test.d.ts +4 -0
- package/dist/use-cases/__tests__/request-authorization.test.js +40 -0
- package/dist/use-cases/request-authorization.d.ts +15 -0
- package/dist/use-cases/request-authorization.js +13 -0
- package/package.json +3 -1
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -0
- package/templates/environment/core/{{env.name}}/imports.tf.hbs +34 -0
- package/templates/environment/core/{{env.name}}/providers.tf.hbs +4 -0
|
@@ -93,3 +93,38 @@ describe("getTerraformBackend", () => {
|
|
|
93
93
|
}));
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
describe("isInitialized", () => {
|
|
97
|
+
test("returns true when both bootstrap identity and common Key Vault exist", async ({ cloudAccountService, }) => {
|
|
98
|
+
// First call: identity query → found
|
|
99
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
100
|
+
// Second call: key vault query → found
|
|
101
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
102
|
+
const result = await cloudAccountService.isInitialized("sub-1", {
|
|
103
|
+
name: "dev",
|
|
104
|
+
prefix: "dx",
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
test("returns false when bootstrap identity exists but common Key Vault does not", async ({ cloudAccountService, }) => {
|
|
109
|
+
// First call: identity query → found
|
|
110
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
111
|
+
// Second call: key vault query → not found
|
|
112
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 0 });
|
|
113
|
+
const result = await cloudAccountService.isInitialized("sub-1", {
|
|
114
|
+
name: "dev",
|
|
115
|
+
prefix: "dx",
|
|
116
|
+
});
|
|
117
|
+
expect(result).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
test("returns false when common Key Vault exists but bootstrap identity does not", async ({ cloudAccountService, }) => {
|
|
120
|
+
// First call: identity query → not found
|
|
121
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 0 });
|
|
122
|
+
// Second call: key vault query → found
|
|
123
|
+
queryResources.mockResolvedValueOnce({ data: [], totalRecords: 1 });
|
|
124
|
+
const result = await cloudAccountService.isInitialized("sub-1", {
|
|
125
|
+
name: "dev",
|
|
126
|
+
prefix: "dx",
|
|
127
|
+
});
|
|
128
|
+
expect(result).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { TokenCredential } from "@azure/identity";
|
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { CloudAccount, type CloudAccountService } from "../../domain/cloud-account.js";
|
|
4
4
|
import { type EnvironmentId } from "../../domain/environment.js";
|
|
5
|
+
import { GitHubAppCredentials } from "../../domain/github.js";
|
|
5
6
|
import { type TerraformBackend } from "../../domain/remote-backend.js";
|
|
6
7
|
export declare const resourceGraphDataSchema: z.ZodObject<{
|
|
7
8
|
location: z.ZodEnum<{
|
|
@@ -16,7 +17,7 @@ export declare class AzureCloudAccountService implements CloudAccountService {
|
|
|
16
17
|
constructor(credential: TokenCredential);
|
|
17
18
|
getTerraformBackend(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<TerraformBackend | undefined>;
|
|
18
19
|
hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
|
|
19
|
-
initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<void>;
|
|
20
|
+
initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, tags?: Record<string, string>): Promise<void>;
|
|
20
21
|
isInitialized(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<boolean>;
|
|
21
22
|
provisionTerraformBackend(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
|
|
22
23
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { AuthorizationManagementClient } from "@azure/arm-authorization";
|
|
2
|
+
import { KeyVaultManagementClient } from "@azure/arm-keyvault";
|
|
2
3
|
import { ManagedServiceIdentityClient } from "@azure/arm-msi";
|
|
3
4
|
import { ResourceGraphClient } from "@azure/arm-resourcegraph";
|
|
4
5
|
import { ResourceManagementClient } from "@azure/arm-resources";
|
|
6
|
+
import { SubscriptionClient } from "@azure/arm-resources-subscriptions";
|
|
5
7
|
import { StorageManagementClient } from "@azure/arm-storage";
|
|
8
|
+
import { SecretClient } from "@azure/keyvault-secrets";
|
|
6
9
|
import { BlobServiceClient } from "@azure/storage-blob";
|
|
7
10
|
import { getLogger } from "@logtape/logtape";
|
|
8
11
|
import { Client } from "@microsoft/microsoft-graph-client";
|
|
@@ -118,7 +121,7 @@ export class AzureCloudAccountService {
|
|
|
118
121
|
throw error;
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
|
-
async initialize(cloudAccount, { name, prefix }, tags = {}) {
|
|
124
|
+
async initialize(cloudAccount, { name, prefix }, runnerAppCredentials, tags = {}) {
|
|
122
125
|
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
|
|
123
126
|
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
|
|
124
127
|
const logger = getLogger(["gen", "env"]);
|
|
@@ -127,7 +130,7 @@ export class AzureCloudAccountService {
|
|
|
127
130
|
env: environmentShort[name],
|
|
128
131
|
location: locationShort[cloudAccount.defaultLocation],
|
|
129
132
|
};
|
|
130
|
-
const resourceGroupName = `${prefix}-${short.env}-${short.location}-
|
|
133
|
+
const resourceGroupName = `${prefix}-${short.env}-${short.location}-common-rg-01`;
|
|
131
134
|
const parameters = {
|
|
132
135
|
location: cloudAccount.defaultLocation,
|
|
133
136
|
tags: {
|
|
@@ -137,24 +140,81 @@ export class AzureCloudAccountService {
|
|
|
137
140
|
};
|
|
138
141
|
await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
|
|
139
142
|
logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
try {
|
|
144
|
+
const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
|
|
145
|
+
const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
|
|
146
|
+
await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
|
|
147
|
+
logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
148
|
+
// Retrieve tenant ID from the subscription
|
|
149
|
+
const subscriptionClient = new SubscriptionClient(this.#credential);
|
|
150
|
+
const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
|
|
151
|
+
assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
|
|
152
|
+
const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
|
|
153
|
+
const keyVaultName = `${prefix}-${short.env}-${short.location}-common-kv-01`;
|
|
154
|
+
const secretsProtectionEnabled = short.env === "p";
|
|
155
|
+
await kvClient.vaults.beginCreateOrUpdateAndWait(resourceGroupName, keyVaultName, {
|
|
156
|
+
location: cloudAccount.defaultLocation,
|
|
157
|
+
properties: {
|
|
158
|
+
enabledForDiskEncryption: true,
|
|
159
|
+
enablePurgeProtection: secretsProtectionEnabled ? true : undefined,
|
|
160
|
+
enableRbacAuthorization: true,
|
|
161
|
+
sku: {
|
|
162
|
+
family: "A",
|
|
163
|
+
name: "standard",
|
|
164
|
+
},
|
|
165
|
+
softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
|
|
166
|
+
tenantId: subscription.tenantId,
|
|
167
|
+
},
|
|
168
|
+
tags: {
|
|
169
|
+
Environment: name,
|
|
170
|
+
...tags,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
logger.debug("Created key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
|
|
174
|
+
const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
|
|
175
|
+
await Promise.all([
|
|
176
|
+
secretClient.setSecret("github-runner-app-id", runnerAppCredentials.id),
|
|
177
|
+
secretClient.setSecret("github-runner-app-installation-id", runnerAppCredentials.installationId),
|
|
178
|
+
secretClient.setSecret("github-runner-app-key", runnerAppCredentials.key),
|
|
179
|
+
]);
|
|
180
|
+
logger.debug("Created secrets in key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
// Cleanup resource group if initialization fails
|
|
184
|
+
await resourceManagementClient.resourceGroups.beginDeleteAndWait(resourceGroupName);
|
|
185
|
+
logger.debug("Deleted resource group {resourceGroupName} in subscription {subscriptionId} due to initialization failure", { resourceGroupName, subscriptionId: cloudAccount.id });
|
|
186
|
+
if (cause instanceof Error) {
|
|
187
|
+
logger.error(cause.message);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Error during the initialization of the cloud account`, {
|
|
190
|
+
cause,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
144
193
|
}
|
|
145
194
|
async isInitialized(cloudAccountId, { name, prefix }) {
|
|
146
195
|
const allLocations = Object.values(locationShort).join("|");
|
|
147
196
|
const shortEnv = environmentShort[name];
|
|
148
|
-
const
|
|
149
|
-
const
|
|
197
|
+
const identityResourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`;
|
|
198
|
+
const identityQuery = `resources
|
|
150
199
|
| where type == 'microsoft.managedidentity/userassignedidentities'
|
|
151
|
-
| where name matches regex @'${
|
|
200
|
+
| where name matches regex @'${identityResourceName}'
|
|
152
201
|
`;
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
202
|
+
const keyVaultResourceName = `${prefix}-${shortEnv}-(${allLocations})-common-kv-(0[1-9]|[1-9]\\d)`;
|
|
203
|
+
const keyVaultQuery = `resources
|
|
204
|
+
| where type == 'microsoft.keyvault/vaults'
|
|
205
|
+
| where name matches regex @'${keyVaultResourceName}'
|
|
206
|
+
`;
|
|
207
|
+
const [identityResult, keyVaultResult] = await Promise.all([
|
|
208
|
+
this.#resourceGraphClient.resources({
|
|
209
|
+
query: identityQuery,
|
|
210
|
+
subscriptions: [cloudAccountId],
|
|
211
|
+
}),
|
|
212
|
+
this.#resourceGraphClient.resources({
|
|
213
|
+
query: keyVaultQuery,
|
|
214
|
+
subscriptions: [cloudAccountId],
|
|
215
|
+
}),
|
|
216
|
+
]);
|
|
217
|
+
const initialized = identityResult.totalRecords > 0 && keyVaultResult.totalRecords > 0;
|
|
158
218
|
const logger = getLogger(["gen", "env"]);
|
|
159
219
|
logger.debug("subscription {subscriptionId} initialized: {initialized}", {
|
|
160
220
|
initialized,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add command - Scaffold new components into existing workspaces
|
|
3
|
+
*
|
|
4
|
+
* This module implements the `dx add` command which allows developers to scaffold
|
|
5
|
+
* new components into their existing workspace following DevEx guidelines.
|
|
6
|
+
*
|
|
7
|
+
* Currently supported components:
|
|
8
|
+
* - environment: Add a new deployment environment to the project
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
export declare const makeAddCommand: () => Command;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add command - Scaffold new components into existing workspaces
|
|
3
|
+
*
|
|
4
|
+
* This module implements the `dx add` command which allows developers to scaffold
|
|
5
|
+
* new components into their existing workspace following DevEx guidelines.
|
|
6
|
+
*
|
|
7
|
+
* Currently supported components:
|
|
8
|
+
* - environment: Add a new deployment environment to the project
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { ResultAsync } from "neverthrow";
|
|
12
|
+
import { getPlopInstance, runDeploymentEnvironmentGenerator, } from "../../plop/index.js";
|
|
13
|
+
import { checkPreconditions } from "./init.js";
|
|
14
|
+
const addEnvironmentAction = () => checkPreconditions()
|
|
15
|
+
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
|
|
16
|
+
.andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop), () => new Error("Failed to run the deployment environment generator")));
|
|
17
|
+
export const makeAddCommand = () => new Command()
|
|
18
|
+
.name("add")
|
|
19
|
+
.description("Add a new component to your workspace")
|
|
20
|
+
.addCommand(new Command("environment")
|
|
21
|
+
.description("Add a new deployment environment")
|
|
22
|
+
.action(async function () {
|
|
23
|
+
const result = await addEnvironmentAction();
|
|
24
|
+
if (result.isErr()) {
|
|
25
|
+
this.error(result.error.message);
|
|
26
|
+
}
|
|
27
|
+
}));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { ResultAsync } from "neverthrow";
|
|
2
3
|
import { GitHubService } from "../../../domain/github.js";
|
|
4
|
+
export declare const checkPreconditions: () => ResultAsync<string, Error>;
|
|
3
5
|
type InitCommandDependencies = {
|
|
4
6
|
gitHubService: GitHubService;
|
|
5
7
|
};
|
|
@@ -49,7 +49,7 @@ const ensureAzLogin = async () => {
|
|
|
49
49
|
return user.name;
|
|
50
50
|
};
|
|
51
51
|
const checkAzLogin = () => withSpinner("Check Azure login status...", (userName) => `You are logged in to Azure (${userName})`, "Please log in to Azure CLI using `az login` before running this command.", ensureAzLogin());
|
|
52
|
-
const checkPreconditions = () => checkTerraformCliIsInstalled().andThen(() => checkAzLogin());
|
|
52
|
+
export const checkPreconditions = () => checkTerraformCliIsInstalled().andThen(() => checkAzLogin());
|
|
53
53
|
const createRemoteRepository = ({ repoName, repoOwner, }) => {
|
|
54
54
|
const logger = getLogger(["dx-cli", "init"]);
|
|
55
55
|
const repo$ = tf$({ cwd: path.resolve("infra", "repository") });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { makeAddCommand } from "./commands/add.js";
|
|
2
3
|
import { makeCodemodCommand, } from "./commands/codemod.js";
|
|
3
4
|
import { makeDoctorCommand } from "./commands/doctor.js";
|
|
4
5
|
import { makeInfoCommand } from "./commands/info.js";
|
|
@@ -12,6 +13,7 @@ export const makeCli = (deps, config, cliDeps, version) => {
|
|
|
12
13
|
program.addCommand(makeInitCommand(deps));
|
|
13
14
|
program.addCommand(makeSavemoneyCommand());
|
|
14
15
|
program.addCommand(makeInfoCommand(deps));
|
|
16
|
+
program.addCommand(makeAddCommand());
|
|
15
17
|
return program;
|
|
16
18
|
};
|
|
17
19
|
export const exitWithError = (command) => (error) => {
|
|
@@ -3,7 +3,7 @@ import { RequestError } from "octokit";
|
|
|
3
3
|
import { SemVer } from "semver";
|
|
4
4
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { mockDeep, mockReset } from "vitest-mock-extended";
|
|
6
|
-
import { PullRequest, Repository, RepositoryNotFoundError, } from "../../../domain/github.js";
|
|
6
|
+
import { FileNotFoundError, PullRequest, Repository, RepositoryNotFoundError, } from "../../../domain/github.js";
|
|
7
7
|
import { fetchLatestRelease, fetchLatestTag } from "../index.js";
|
|
8
8
|
import { OctokitGitHubService } from "../index.js";
|
|
9
9
|
const makeEnv = () => {
|
|
@@ -14,6 +14,7 @@ const makeEnv = () => {
|
|
|
14
14
|
mockOctokit,
|
|
15
15
|
};
|
|
16
16
|
};
|
|
17
|
+
// eslint-disable-next-line max-lines-per-function
|
|
17
18
|
describe("OctokitGitHubService", () => {
|
|
18
19
|
describe("getRepository", () => {
|
|
19
20
|
it("should return a Repository when the repository exists", async () => {
|
|
@@ -140,6 +141,222 @@ describe("OctokitGitHubService", () => {
|
|
|
140
141
|
await expect(githubService.createPullRequest(params)).rejects.toThrowError("Failed to create pull request in pagopa/dx");
|
|
141
142
|
});
|
|
142
143
|
});
|
|
144
|
+
describe("getFileContent", () => {
|
|
145
|
+
it("should return file content and sha when file exists", async () => {
|
|
146
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
147
|
+
const params = {
|
|
148
|
+
owner: "pagopa",
|
|
149
|
+
path: "src/test.tf",
|
|
150
|
+
ref: "main",
|
|
151
|
+
repo: "test-repo",
|
|
152
|
+
};
|
|
153
|
+
const fileContent = "test content";
|
|
154
|
+
const mockResponse = {
|
|
155
|
+
data: {
|
|
156
|
+
content: Buffer.from(fileContent).toString("base64"),
|
|
157
|
+
sha: "abc123sha",
|
|
158
|
+
type: "file",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
mockOctokit.rest.repos.getContent.mockResolvedValue(mockResponse);
|
|
162
|
+
const result = await githubService.getFileContent(params);
|
|
163
|
+
expect(result.content).toBe(fileContent);
|
|
164
|
+
expect(result.sha).toBe("abc123sha");
|
|
165
|
+
expect(mockOctokit.rest.repos.getContent).toHaveBeenCalledWith({
|
|
166
|
+
owner: params.owner,
|
|
167
|
+
path: params.path,
|
|
168
|
+
ref: params.ref,
|
|
169
|
+
repo: params.repo,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
it("should throw FileNotFoundError when file does not exist (404)", async () => {
|
|
173
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
174
|
+
const params = {
|
|
175
|
+
owner: "pagopa",
|
|
176
|
+
path: "non-existent.tf",
|
|
177
|
+
repo: "test-repo",
|
|
178
|
+
};
|
|
179
|
+
const error = new RequestError("Not Found", 404, {
|
|
180
|
+
request: {
|
|
181
|
+
headers: {},
|
|
182
|
+
method: "GET",
|
|
183
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/non-existent.tf",
|
|
184
|
+
},
|
|
185
|
+
response: {
|
|
186
|
+
data: { message: "Not Found" },
|
|
187
|
+
headers: {},
|
|
188
|
+
status: 404,
|
|
189
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/non-existent.tf",
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
mockOctokit.rest.repos.getContent.mockRejectedValue(error);
|
|
193
|
+
await expect(githubService.getFileContent(params)).rejects.toThrow(FileNotFoundError);
|
|
194
|
+
await expect(githubService.getFileContent(params)).rejects.toThrowError("File not found: non-existent.tf");
|
|
195
|
+
});
|
|
196
|
+
it("should throw an error when API call fails", async () => {
|
|
197
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
198
|
+
const params = {
|
|
199
|
+
owner: "pagopa",
|
|
200
|
+
path: "src/test.tf",
|
|
201
|
+
repo: "test-repo",
|
|
202
|
+
};
|
|
203
|
+
const error = new RequestError("Server Error", 500, {
|
|
204
|
+
request: {
|
|
205
|
+
headers: {},
|
|
206
|
+
method: "GET",
|
|
207
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/src/test.tf",
|
|
208
|
+
},
|
|
209
|
+
response: {
|
|
210
|
+
data: { message: "Server Error" },
|
|
211
|
+
headers: {},
|
|
212
|
+
status: 500,
|
|
213
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/src/test.tf",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
mockOctokit.rest.repos.getContent.mockRejectedValue(error);
|
|
217
|
+
await expect(githubService.getFileContent(params)).rejects.toThrowError("Failed to get file content: src/test.tf");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe("createBranch", () => {
|
|
221
|
+
it("should create a new branch from an existing ref", async () => {
|
|
222
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
223
|
+
const params = {
|
|
224
|
+
branchName: "feats/new-feature",
|
|
225
|
+
fromRef: "main",
|
|
226
|
+
owner: "pagopa",
|
|
227
|
+
repo: "test-repo",
|
|
228
|
+
};
|
|
229
|
+
const refResponse = {
|
|
230
|
+
data: {
|
|
231
|
+
object: {
|
|
232
|
+
sha: "mainbranchsha123",
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
mockOctokit.rest.git.getRef.mockResolvedValue(refResponse);
|
|
237
|
+
mockOctokit.rest.git.createRef.mockResolvedValue({});
|
|
238
|
+
await githubService.createBranch(params);
|
|
239
|
+
expect(mockOctokit.rest.git.getRef).toHaveBeenCalledWith({
|
|
240
|
+
owner: params.owner,
|
|
241
|
+
ref: "heads/main",
|
|
242
|
+
repo: params.repo,
|
|
243
|
+
});
|
|
244
|
+
expect(mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
|
|
245
|
+
owner: params.owner,
|
|
246
|
+
ref: "refs/heads/feats/new-feature",
|
|
247
|
+
repo: params.repo,
|
|
248
|
+
sha: "mainbranchsha123",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
it("should throw an error when branch creation fails", async () => {
|
|
252
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
253
|
+
const params = {
|
|
254
|
+
branchName: "feats/new-feature",
|
|
255
|
+
fromRef: "main",
|
|
256
|
+
owner: "pagopa",
|
|
257
|
+
repo: "test-repo",
|
|
258
|
+
};
|
|
259
|
+
const refResponse = {
|
|
260
|
+
data: {
|
|
261
|
+
object: {
|
|
262
|
+
sha: "mainbranchsha123",
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
const error = new RequestError("Reference already exists", 422, {
|
|
267
|
+
request: {
|
|
268
|
+
headers: {},
|
|
269
|
+
method: "POST",
|
|
270
|
+
url: "https://api.github.com/repos/pagopa/test-repo/git/refs",
|
|
271
|
+
},
|
|
272
|
+
response: {
|
|
273
|
+
data: { message: "Reference already exists" },
|
|
274
|
+
headers: {},
|
|
275
|
+
status: 422,
|
|
276
|
+
url: "https://api.github.com/repos/pagopa/test-repo/git/refs",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
mockOctokit.rest.git.getRef.mockResolvedValue(refResponse);
|
|
280
|
+
mockOctokit.rest.git.createRef.mockRejectedValue(error);
|
|
281
|
+
await expect(githubService.createBranch(params)).rejects.toThrowError("Failed to create branch: feats/new-feature");
|
|
282
|
+
});
|
|
283
|
+
it("should throw an error when source ref does not exist", async () => {
|
|
284
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
285
|
+
const params = {
|
|
286
|
+
branchName: "feats/new-feature",
|
|
287
|
+
fromRef: "non-existent",
|
|
288
|
+
owner: "pagopa",
|
|
289
|
+
repo: "test-repo",
|
|
290
|
+
};
|
|
291
|
+
const error = new RequestError("Not Found", 404, {
|
|
292
|
+
request: {
|
|
293
|
+
headers: {},
|
|
294
|
+
method: "GET",
|
|
295
|
+
url: "https://api.github.com/repos/pagopa/test-repo/git/ref/heads/non-existent",
|
|
296
|
+
},
|
|
297
|
+
response: {
|
|
298
|
+
data: { message: "Not Found" },
|
|
299
|
+
headers: {},
|
|
300
|
+
status: 404,
|
|
301
|
+
url: "https://api.github.com/repos/pagopa/test-repo/git/ref/heads/non-existent",
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
mockOctokit.rest.git.getRef.mockRejectedValue(error);
|
|
305
|
+
await expect(githubService.createBranch(params)).rejects.toThrowError("Failed to create branch: feats/new-feature");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
describe("updateFile", () => {
|
|
309
|
+
it("should update a file successfully", async () => {
|
|
310
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
311
|
+
const params = {
|
|
312
|
+
branch: "feats/new-feature",
|
|
313
|
+
content: "updated content",
|
|
314
|
+
message: "Update file",
|
|
315
|
+
owner: "pagopa",
|
|
316
|
+
path: "src/test.tf",
|
|
317
|
+
repo: "test-repo",
|
|
318
|
+
sha: "existingfilesha123",
|
|
319
|
+
};
|
|
320
|
+
mockOctokit.rest.repos.createOrUpdateFileContents.mockResolvedValue({});
|
|
321
|
+
await githubService.updateFile(params);
|
|
322
|
+
expect(mockOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith({
|
|
323
|
+
branch: params.branch,
|
|
324
|
+
content: Buffer.from(params.content).toString("base64"),
|
|
325
|
+
message: params.message,
|
|
326
|
+
owner: params.owner,
|
|
327
|
+
path: params.path,
|
|
328
|
+
repo: params.repo,
|
|
329
|
+
sha: params.sha,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
it("should throw an error when file update fails", async () => {
|
|
333
|
+
const { githubService, mockOctokit } = makeEnv();
|
|
334
|
+
const params = {
|
|
335
|
+
branch: "feats/new-feature",
|
|
336
|
+
content: "updated content",
|
|
337
|
+
message: "Update file",
|
|
338
|
+
owner: "pagopa",
|
|
339
|
+
path: "src/test.tf",
|
|
340
|
+
repo: "test-repo",
|
|
341
|
+
sha: "wrongsha",
|
|
342
|
+
};
|
|
343
|
+
const error = new RequestError("Conflict", 409, {
|
|
344
|
+
request: {
|
|
345
|
+
headers: {},
|
|
346
|
+
method: "PUT",
|
|
347
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/src/test.tf",
|
|
348
|
+
},
|
|
349
|
+
response: {
|
|
350
|
+
data: { message: "Conflict" },
|
|
351
|
+
headers: {},
|
|
352
|
+
status: 409,
|
|
353
|
+
url: "https://api.github.com/repos/pagopa/test-repo/contents/src/test.tf",
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
mockOctokit.rest.repos.createOrUpdateFileContents.mockRejectedValue(error);
|
|
357
|
+
await expect(githubService.updateFile(params)).rejects.toThrowError("Failed to update file: src/test.tf");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
143
360
|
});
|
|
144
361
|
describe("octokit adapter", () => {
|
|
145
362
|
const owner = "test-owner";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ResultAsync } from "neverthrow";
|
|
2
2
|
import { Octokit } from "octokit";
|
|
3
|
-
import { GitHubService, PullRequest, Repository } from "../../domain/github.js";
|
|
3
|
+
import { CreateBranchParams, FileContent, GetFileContentParams, GitHubService, PullRequest, Repository, UpdateFileParams } from "../../domain/github.js";
|
|
4
4
|
type GitHubReleaseParam = {
|
|
5
5
|
client: Octokit;
|
|
6
6
|
owner: string;
|
|
@@ -9,6 +9,7 @@ type GitHubReleaseParam = {
|
|
|
9
9
|
export declare class OctokitGitHubService implements GitHubService {
|
|
10
10
|
#private;
|
|
11
11
|
constructor(octokit: Octokit);
|
|
12
|
+
createBranch(params: CreateBranchParams): Promise<void>;
|
|
12
13
|
createPullRequest(params: {
|
|
13
14
|
base: string;
|
|
14
15
|
body: string;
|
|
@@ -17,7 +18,9 @@ export declare class OctokitGitHubService implements GitHubService {
|
|
|
17
18
|
repo: string;
|
|
18
19
|
title: string;
|
|
19
20
|
}): Promise<PullRequest>;
|
|
21
|
+
getFileContent(params: GetFileContentParams): Promise<FileContent>;
|
|
20
22
|
getRepository(owner: string, name: string): Promise<Repository>;
|
|
23
|
+
updateFile(params: UpdateFileParams): Promise<void>;
|
|
21
24
|
}
|
|
22
25
|
export declare const getGitHubPAT: () => Promise<string | undefined>;
|
|
23
26
|
export declare const fetchLatestTag: ({ client, owner, repo }: GitHubReleaseParam) => ResultAsync<import("semver").SemVer | null, Error>;
|
|
@@ -3,12 +3,34 @@ import { ResultAsync } from "neverthrow";
|
|
|
3
3
|
import { RequestError } from "octokit";
|
|
4
4
|
import semverParse from "semver/functions/parse.js";
|
|
5
5
|
import semverSort from "semver/functions/sort.js";
|
|
6
|
-
import { PullRequest, Repository, RepositoryNotFoundError, } from "../../domain/github.js";
|
|
6
|
+
import { FileNotFoundError, PullRequest, Repository, RepositoryNotFoundError, } from "../../domain/github.js";
|
|
7
7
|
export class OctokitGitHubService {
|
|
8
8
|
#octokit;
|
|
9
9
|
constructor(octokit) {
|
|
10
10
|
this.#octokit = octokit;
|
|
11
11
|
}
|
|
12
|
+
async createBranch(params) {
|
|
13
|
+
try {
|
|
14
|
+
// Get the SHA of the source branch
|
|
15
|
+
const { data: refData } = await this.#octokit.rest.git.getRef({
|
|
16
|
+
owner: params.owner,
|
|
17
|
+
ref: `heads/${params.fromRef}`,
|
|
18
|
+
repo: params.repo,
|
|
19
|
+
});
|
|
20
|
+
// Create the new branch
|
|
21
|
+
await this.#octokit.rest.git.createRef({
|
|
22
|
+
owner: params.owner,
|
|
23
|
+
ref: `refs/heads/${params.branchName}`,
|
|
24
|
+
repo: params.repo,
|
|
25
|
+
sha: refData.object.sha,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error(`Failed to create branch: ${params.branchName}`, {
|
|
30
|
+
cause: error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
12
34
|
async createPullRequest(params) {
|
|
13
35
|
try {
|
|
14
36
|
const { data } = await this.#octokit.rest.pulls.create({
|
|
@@ -27,6 +49,30 @@ export class OctokitGitHubService {
|
|
|
27
49
|
});
|
|
28
50
|
}
|
|
29
51
|
}
|
|
52
|
+
async getFileContent(params) {
|
|
53
|
+
try {
|
|
54
|
+
const { data } = await this.#octokit.rest.repos.getContent({
|
|
55
|
+
owner: params.owner,
|
|
56
|
+
path: params.path,
|
|
57
|
+
ref: params.ref,
|
|
58
|
+
repo: params.repo,
|
|
59
|
+
});
|
|
60
|
+
// GitHub API returns an array for directories, single object for files
|
|
61
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
62
|
+
throw new Error(`Path ${params.path} is not a file`);
|
|
63
|
+
}
|
|
64
|
+
const content = Buffer.from(data.content, "base64").toString("utf-8");
|
|
65
|
+
return { content, sha: data.sha };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof RequestError && error.status === 404) {
|
|
69
|
+
throw new FileNotFoundError(params.path);
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Failed to get file content: ${params.path}`, {
|
|
72
|
+
cause: error,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
30
76
|
async getRepository(owner, name) {
|
|
31
77
|
try {
|
|
32
78
|
const { data } = await this.#octokit.rest.repos.get({
|
|
@@ -44,6 +90,24 @@ export class OctokitGitHubService {
|
|
|
44
90
|
});
|
|
45
91
|
}
|
|
46
92
|
}
|
|
93
|
+
async updateFile(params) {
|
|
94
|
+
try {
|
|
95
|
+
await this.#octokit.rest.repos.createOrUpdateFileContents({
|
|
96
|
+
branch: params.branch,
|
|
97
|
+
content: Buffer.from(params.content).toString("base64"),
|
|
98
|
+
message: params.message,
|
|
99
|
+
owner: params.owner,
|
|
100
|
+
path: params.path,
|
|
101
|
+
repo: params.repo,
|
|
102
|
+
sha: params.sha,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new Error(`Failed to update file: ${params.path}`, {
|
|
107
|
+
cause: error,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
47
111
|
}
|
|
48
112
|
// Follow the same order of precedence of gh cli
|
|
49
113
|
// https://cli.github.com/manual/gh_help_environment
|