@pagopa/dx-cli 0.16.3 → 0.18.1

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.
Files changed (40) hide show
  1. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +35 -0
  2. package/dist/adapters/azure/cloud-account-service.d.ts +2 -1
  3. package/dist/adapters/azure/cloud-account-service.js +74 -14
  4. package/dist/adapters/commander/commands/add.d.ts +11 -0
  5. package/dist/adapters/commander/commands/add.js +27 -0
  6. package/dist/adapters/commander/commands/init.d.ts +2 -0
  7. package/dist/adapters/commander/commands/init.js +1 -1
  8. package/dist/adapters/commander/index.js +2 -0
  9. package/dist/adapters/octokit/__tests__/index.test.js +218 -1
  10. package/dist/adapters/octokit/index.d.ts +4 -1
  11. package/dist/adapters/octokit/index.js +65 -1
  12. package/dist/adapters/pagopa-technology/__tests__/authorization.test.d.ts +4 -0
  13. package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +170 -0
  14. package/dist/adapters/pagopa-technology/authorization.d.ts +11 -0
  15. package/dist/adapters/pagopa-technology/authorization.js +104 -0
  16. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +35 -3
  17. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +15 -0
  18. package/dist/adapters/plop/actions/init-cloud-accounts.js +5 -2
  19. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +5 -0
  20. package/dist/adapters/plop/generators/environment/prompts.d.ts +14 -0
  21. package/dist/adapters/plop/generators/environment/prompts.js +67 -31
  22. package/dist/adapters/plop/index.d.ts +18 -2
  23. package/dist/adapters/plop/index.js +16 -0
  24. package/dist/domain/__tests__/data.d.ts +2 -0
  25. package/dist/domain/__tests__/data.js +1 -0
  26. package/dist/domain/authorization.d.ts +49 -0
  27. package/dist/domain/authorization.js +73 -0
  28. package/dist/domain/cloud-account.d.ts +2 -1
  29. package/dist/domain/dependencies.d.ts +2 -0
  30. package/dist/domain/github.d.ts +51 -0
  31. package/dist/domain/github.js +12 -0
  32. package/dist/index.js +5 -0
  33. package/dist/use-cases/__tests__/request-authorization.test.d.ts +4 -0
  34. package/dist/use-cases/__tests__/request-authorization.test.js +40 -0
  35. package/dist/use-cases/request-authorization.d.ts +15 -0
  36. package/dist/use-cases/request-authorization.js +13 -0
  37. package/package.json +5 -3
  38. package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -0
  39. package/templates/environment/core/{{env.name}}/imports.tf.hbs +34 -0
  40. 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}-bootstrap-rg-01`;
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
- const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
141
- const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
142
- await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
143
- logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
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 resourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`;
149
- const query = `resources
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 @'${resourceName}'
200
+ | where name matches regex @'${identityResourceName}'
152
201
  `;
153
- const result = await this.#resourceGraphClient.resources({
154
- query,
155
- subscriptions: [cloudAccountId],
156
- });
157
- const initialized = result.totalRecords > 0;
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
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for the PagoPA technology authorization adapter.
3
+ */
4
+ export {};