diff --git a/src/adapters/git.ts b/src/adapters/git.ts new file mode 100644 index 0000000..0c3a6be --- /dev/null +++ b/src/adapters/git.ts @@ -0,0 +1,275 @@ +import type { RoutingOptions } from '../utils/router'; + +import { Router } from '../utils'; + +// ────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────── + +export interface GitStatusReadOutView { + currentBranch?: string | null; +} + +export interface GitIntegrationReadOutView { + privateKeySpec?: string | null; + publicKey?: string | null; + uri?: string | null; + publicKeySpec?: string | null; + algorithm?: string | null; +} + +export interface GitIntegrationCreateInView { + privateKey: string; + privateKeySpec: unknown; + publicKey: string; + uri: string; + publicKeySpec: unknown; + algorithm: unknown; +} + +export interface GitIntegrationUpdateInView { + privateKey?: string | null; + privateKeySpec: unknown; + publicKey?: string | null; + uri?: string | null; + publicKeySpec: unknown; + algorithm: unknown; +} + + +// ────────────────────────────────────────────── +// Functions +// ────────────────────────────────────────────── + +/** + * Retrieves the git integration configuration for the project. + * Base URL: GET `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * const integration = await gitAdapter.get(); + * + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves to the git integration configuration + */ +export async function get( + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .get('/git', optionals) + .then(({ body }) => body); +} + + +/** + * Retrieves the current git status for the project. + * Base URL: GET `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/status` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * const status = await gitAdapter.getStatus(); + * console.log(status.currentBranch); + * + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves to the git status, including the current branch + */ +export async function getStatus( + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .get('/git/status', optionals) + .then(({ body }) => body); +} + + +/** + * Checks out a branch in the project's git repository. + * Base URL: GET `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/checkout/{branch}` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * await gitAdapter.checkout('main'); + * + * @param branch Name of the branch to check out + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves when the checkout is complete + */ +export async function checkout( + branch: string, + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .get(`/git/checkout/${branch}`, optionals) + .then(({ body }) => body); +} + + +/** + * Resets the project's git repository, optionally to a specific branch. + * Base URL: DELETE `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/reset[/{branch}]` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * await gitAdapter.reset(); // reset current branch + * await gitAdapter.reset({ branch: 'main' }); // reset to 'main' + * + * @param [optionals] Optional arguments; pass network call options overrides here. Special arguments specific to this method are listed below if they exist. + * @param [optionals.branch] Branch to reset to; if omitted, resets the current branch + * @returns promise that resolves when the reset is complete + */ +export async function reset( + optionals: { branch?: string } & RoutingOptions = {}, +): Promise { + const { branch, ...routingOptions } = optionals; + return new Router() + .delete(`/git/reset${branch ? `/${branch}` : ''}`, routingOptions) + .then(({ body }) => body); +} + + +/** + * Creates a git integration for the project. + * Base URL: POST `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/integration` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * const integration = await gitAdapter.createIntegration({ + * uri: 'git@github.com:myorg/myrepo.git', + * publicKey: '...', + * privateKey: '...', + * publicKeySpec: 'ssh-ed25519', + * privateKeySpec: 'OPENSSH', + * algorithm: 'Ed25519', + * }); + * + * @param integration Git integration configuration to create + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves to the created git integration + */ +export async function createIntegration( + integration: GitIntegrationCreateInView, + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .post('/git/integration', { + body: integration, + ...optionals, + }).then(({ body }) => body); +} + + +/** + * Updates the git integration for the project. + * Base URL: PATCH `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/integration` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * const integration = await gitAdapter.updateIntegration({ + * uri: 'git@github.com:myorg/newrepo.git', + * publicKeySpec: 'ssh-ed25519', + * privateKeySpec: 'OPENSSH', + * algorithm: 'Ed25519', + * }); + * + * @param integration Fields to update on the git integration + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves to the updated git integration + */ +export async function updateIntegration( + integration: GitIntegrationUpdateInView, + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .patch('/git/integration', { + body: integration, + ...optionals, + }).then(({ body }) => body); +} + + +/** + * Removes the git integration for the project. + * Base URL: DELETE `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/integration` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * await gitAdapter.removeIntegration(); + * + * @param [optionals] Optional arguments; pass network call options overrides here. + * @returns promise that resolves when the integration is removed + */ +export async function removeIntegration( + optionals: RoutingOptions = {}, +): Promise { + return new Router() + .delete('/git/integration', optionals) + .then(({ body }) => body); +} + + +/** + * Pushes local commits to the remote git repository. + * Base URL: POST `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/push` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * await gitAdapter.push({ message: 'Update simulation data' }); + * + * @param [optionals] Optional arguments; pass network call options overrides here. Special arguments specific to this method are listed below if they exist. + * @param [optionals.message] Commit message + * @param [optionals.password] Password for authentication + * @param [optionals.force] Force-push, bypassing non-fast-forward checks + * @returns promise that resolves when the push is complete + */ +export async function push( + optionals: { + message?: string | null; + password?: string | null; + force?: boolean | null; + } & RoutingOptions = {}, +): Promise { + const { message, password, force, ...routingOptions } = optionals; + return new Router() + .withSearchParams({ force }) + .post('/git/push', { + body: { message, password }, + ...routingOptions, + }).then(({ body }) => body); +} + + +/** + * Pulls changes from the remote git repository into the project. + * Base URL: POST `https://forio.com/api/v3/{ACCOUNT}/{PROJECT}/git/pull` + * + * @example + * import { gitAdapter } from 'epicenter-libs'; + * await gitAdapter.pull({ force: true, confirm: true }); + * + * @param [optionals] Optional arguments; pass network call options overrides here. Special arguments specific to this method are listed below if they exist. + * @param [optionals.password] Password for authentication + * @param [optionals.force] Force the pull, overwriting local changes + * @param [optionals.confirm] Set the `X-Forio-Confirmation` header to confirm an overwrite + * @returns promise that resolves when the pull is complete + */ +export async function pull( + optionals: { + password?: string | null; + force?: boolean | null; + confirm?: boolean | null; + } & RoutingOptions = {}, +): Promise { + const { password, force, confirm, headers: headersOverride, ...routingOptions } = optionals; + const headers = Object.assign( + {}, + headersOverride, + confirm ? { 'X-Forio-Confirmation': true } : {}, + ); + return new Router() + .withSearchParams({ force }) + .post('/git/pull', { + body: { password }, + headers, + ...routingOptions, + }).then(({ body }) => body); +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 2f45dbe..e328f16 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -25,6 +25,7 @@ import * as dailyAdapter from './daily'; import * as walletAdapter from './wallet'; import { default as cometdAdapter } from './cometd'; import { default as Channel } from './channel'; +import * as gitAdapter from './git'; export { accountAdapter, @@ -54,4 +55,5 @@ export { dailyAdapter, walletAdapter, Channel, + gitAdapter, }; diff --git a/src/epicenter.ts b/src/epicenter.ts index 1891fc4..db58cc9 100644 --- a/src/epicenter.ts +++ b/src/epicenter.ts @@ -125,6 +125,7 @@ export { walletAdapter, Channel, cometdAdapter, + gitAdapter, } from './adapters'; /* APIs */ diff --git a/tests/common.js b/tests/common.js index 01b33a1..cdf0deb 100644 --- a/tests/common.js +++ b/tests/common.js @@ -208,6 +208,7 @@ export const { videoAdapter, vonageAdapter, cometdAdapter, + gitAdapter, } = globalThis.epicenter || {}; export const testedMethods = new Set(); diff --git a/tests/git.spec.js b/tests/git.spec.js new file mode 100644 index 0000000..0913d12 --- /dev/null +++ b/tests/git.spec.js @@ -0,0 +1,390 @@ +import { + it, + expect, + describe, + afterAll, + beforeAll, + beforeEach, +} from 'vitest'; +import { + ACCOUNT, + PROJECT, + SESSION, + GENERIC_OPTIONS, + createFetchMock, + getAuthHeader, + getPermitHeader, + testedMethods, + config, + authAdapter, + gitAdapter, + getFunctionKeys, +} from './common'; + +describe('gitAdapter', () => { + let capturedRequests = []; + let mockSetup; + + config.accountShortName = ACCOUNT; + config.projectShortName = PROJECT; + + const BASE_URL = `https://${config.apiHost}/api/v${config.apiVersion}/${ACCOUNT}/${PROJECT}`; + + const INTEGRATION_CREATE = { + uri: 'git@github.com:myorg/myrepo.git', + publicKey: 'ssh-ed25519 AAAA...', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----', + publicKeySpec: 'ssh-ed25519', + privateKeySpec: 'OPENSSH', + algorithm: 'Ed25519', + }; + + const INTEGRATION_UPDATE = { + uri: 'git@github.com:myorg/myrepo.git', + publicKeySpec: 'ssh-ed25519', + privateKeySpec: 'OPENSSH', + algorithm: 'Ed25519', + }; + + beforeAll(() => { + mockSetup = createFetchMock(); + capturedRequests = mockSetup.capturedRequests; + }); + + beforeEach(() => { + capturedRequests.length = 0; + authAdapter.setLocalSession(SESSION); + }); + + afterAll(() => { + mockSetup.restore(); + authAdapter.setLocalSession(undefined); + }); + + describe('gitAdapter.get', () => { + it('Should do a GET', async () => { + await gitAdapter.get(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('GET'); + }); + + it('Should have authorization', async () => { + await gitAdapter.get(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git URL', async () => { + await gitAdapter.get(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.get(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git`); + }); + + testedMethods.add('get'); + }); + + describe('gitAdapter.getStatus', () => { + it('Should do a GET', async () => { + await gitAdapter.getStatus(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('GET'); + }); + + it('Should have authorization', async () => { + await gitAdapter.getStatus(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/status URL', async () => { + await gitAdapter.getStatus(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/status`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.getStatus(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/status`); + }); + + testedMethods.add('getStatus'); + }); + + describe('gitAdapter.checkout', () => { + const BRANCH = 'main'; + + it('Should do a GET', async () => { + await gitAdapter.checkout(BRANCH); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('GET'); + }); + + it('Should have authorization', async () => { + await gitAdapter.checkout(BRANCH); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/checkout/{branch} URL', async () => { + await gitAdapter.checkout(BRANCH); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/checkout/${BRANCH}`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.checkout(BRANCH, GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/checkout/${BRANCH}`); + }); + + testedMethods.add('checkout'); + }); + + describe('gitAdapter.reset', () => { + it('Should do a DELETE', async () => { + await gitAdapter.reset(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('DELETE'); + }); + + it('Should have authorization', async () => { + await gitAdapter.reset(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/reset URL when no branch is provided', async () => { + await gitAdapter.reset(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/reset`); + }); + + it('Should use the git/reset/{branch} URL when branch is provided', async () => { + await gitAdapter.reset({ branch: 'main' }); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/reset/main`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.reset(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/reset`); + }); + + testedMethods.add('reset'); + }); + + describe('gitAdapter.createIntegration', () => { + it('Should do a POST', async () => { + await gitAdapter.createIntegration(INTEGRATION_CREATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('POST'); + }); + + it('Should have authorization', async () => { + await gitAdapter.createIntegration(INTEGRATION_CREATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/integration URL', async () => { + await gitAdapter.createIntegration(INTEGRATION_CREATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/integration`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.createIntegration(INTEGRATION_CREATE, GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/integration`); + }); + + it('Should send the integration config in the request body', async () => { + await gitAdapter.createIntegration(INTEGRATION_CREATE); + const req = capturedRequests[capturedRequests.length - 1]; + const body = JSON.parse(req.options.body); + expect(body).toEqual(INTEGRATION_CREATE); + }); + + testedMethods.add('createIntegration'); + }); + + describe('gitAdapter.updateIntegration', () => { + it('Should do a PATCH', async () => { + await gitAdapter.updateIntegration(INTEGRATION_UPDATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('PATCH'); + }); + + it('Should have authorization', async () => { + await gitAdapter.updateIntegration(INTEGRATION_UPDATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/integration URL', async () => { + await gitAdapter.updateIntegration(INTEGRATION_UPDATE); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/integration`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.updateIntegration(INTEGRATION_UPDATE, GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/integration`); + }); + + it('Should send the integration fields in the request body', async () => { + await gitAdapter.updateIntegration(INTEGRATION_UPDATE); + const req = capturedRequests[capturedRequests.length - 1]; + const body = JSON.parse(req.options.body); + expect(body).toEqual(INTEGRATION_UPDATE); + }); + + testedMethods.add('updateIntegration'); + }); + + describe('gitAdapter.removeIntegration', () => { + it('Should do a DELETE', async () => { + await gitAdapter.removeIntegration(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('DELETE'); + }); + + it('Should have authorization', async () => { + await gitAdapter.removeIntegration(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/integration URL', async () => { + await gitAdapter.removeIntegration(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/integration`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.removeIntegration(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/integration`); + }); + + testedMethods.add('removeIntegration'); + }); + + describe('gitAdapter.push', () => { + it('Should do a POST', async () => { + await gitAdapter.push(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('POST'); + }); + + it('Should have authorization', async () => { + await gitAdapter.push(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/push URL', async () => { + await gitAdapter.push(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/push`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.push(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/push`); + }); + + it('Should send message and password in the request body', async () => { + await gitAdapter.push({ message: 'my commit', password: 'secret' }); + const req = capturedRequests[capturedRequests.length - 1]; + const body = JSON.parse(req.options.body); + expect(body.message).toBe('my commit'); + expect(body.password).toBe('secret'); + }); + + it('Should append force as a query parameter', async () => { + await gitAdapter.push({ force: true }); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toContain('force=true'); + }); + + testedMethods.add('push'); + }); + + describe('gitAdapter.pull', () => { + it('Should do a POST', async () => { + await gitAdapter.pull(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.options.method.toUpperCase()).toBe('POST'); + }); + + it('Should have authorization', async () => { + await gitAdapter.pull(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getAuthHeader(req.requestHeaders)).toBe(`Bearer ${SESSION.token}`); + }); + + it('Should use the git/pull URL', async () => { + await gitAdapter.pull(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toBe(`${BASE_URL}/git/pull`); + }); + + it('Should support generic URL options', async () => { + await gitAdapter.pull(GENERIC_OPTIONS); + const req = capturedRequests[capturedRequests.length - 1]; + const { server, accountShortName, projectShortName } = GENERIC_OPTIONS; + expect(req.url).toBe(`${server}/api/v${config.apiVersion}/${accountShortName}/${projectShortName}/git/pull`); + }); + + it('Should send password in the request body', async () => { + await gitAdapter.pull({ password: 'secret' }); + const req = capturedRequests[capturedRequests.length - 1]; + const body = JSON.parse(req.options.body); + expect(body.password).toBe('secret'); + }); + + it('Should append force as a query parameter', async () => { + await gitAdapter.pull({ force: true }); + const req = capturedRequests[capturedRequests.length - 1]; + expect(req.url).toContain('force=true'); + }); + + it('Should set X-Forio-Confirmation header when confirm is true', async () => { + await gitAdapter.pull({ confirm: true }); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getPermitHeader(req.requestHeaders)).toBeTruthy(); + }); + + it('Should not set X-Forio-Confirmation header when confirm is falsy', async () => { + await gitAdapter.pull(); + const req = capturedRequests[capturedRequests.length - 1]; + expect(getPermitHeader(req.requestHeaders)).toBeFalsy(); + }); + + testedMethods.add('pull'); + }); + + it('Should not have any untested methods', () => { + const actualMethods = getFunctionKeys(gitAdapter); + expect(actualMethods).toEqual(testedMethods); + }); +});