From c76e863eff4d48aaf171a2104a1d40550e9909b1 Mon Sep 17 00:00:00 2001 From: Mathusan Selvarajah Date: Tue, 19 Mar 2024 11:28:27 -0400 Subject: [PATCH] add devconnect api client (#6887) Co-authored-by: Mathusan Selvarajah --- src/api.ts | 10 ++ src/gcp/devConnect.ts | 244 ++++++++++++++++++++++++++++++++ src/test/gcp/devconnect.spec.ts | 70 +++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/gcp/devConnect.ts create mode 100644 src/test/gcp/devconnect.spec.ts diff --git a/src/api.ts b/src/api.ts index 401be9e0924..f8c4aa759d5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -100,6 +100,16 @@ export const cloudbuildOrigin = utils.envOverride( "https://cloudbuild.googleapis.com", ); +export const developerConnectOrigin = utils.envOverride( + "FIREBASE_DEVELOPERCONNECT_URL", + "https://developerconnect.googleapis.com", +); + +export const developerConnectP4SAOrigin = utils.envOverride( + "FIREBASE_DEVELOPERCONNECT_P4SA_URL", + "gcp-sa-developerconnect.iam.gserviceaccount.com", +); + export const cloudschedulerOrigin = utils.envOverride( "FIREBASE_CLOUDSCHEDULER_URL", "https://cloudscheduler.googleapis.com", diff --git a/src/gcp/devConnect.ts b/src/gcp/devConnect.ts new file mode 100644 index 00000000000..d7fde818c66 --- /dev/null +++ b/src/gcp/devConnect.ts @@ -0,0 +1,244 @@ +import { Client } from "../apiv2"; +import { developerConnectOrigin, developerConnectP4SAOrigin } from "../api"; + +const PAGE_SIZE_MAX = 100; + +export const client = new Client({ + urlPrefix: developerConnectOrigin, + auth: true, + apiVersion: "v1", +}); + +export interface OperationMetadata { + createTime: string; + endTime: string; + target: string; + verb: string; + requestedCancellation: boolean; + apiVersion: string; +} + +export interface Operation { + name: string; + metadata?: OperationMetadata; + done: boolean; + error?: { code: number; message: string; details: unknown }; + response?: any; +} + +export interface OAuthCredential { + oauthTokenSecretVersion: string; + username: string; +} + +type GitHubApp = "GIT_HUB_APP_UNSPECIFIED" | "DEVELOPER_CONNECT" | "FIREBASE"; + +export interface GitHubConfig { + githubApp?: GitHubApp; + authorizerCredential?: OAuthCredential; + appInstallationId?: string; + installationUri?: string; +} + +type InstallationStage = + | "STAGE_UNSPECIFIED" + | "PENDING_CREATE_APP" + | "PENDING_USER_OAUTH" + | "PENDING_INSTALL_APP" + | "COMPLETE"; + +export interface InstallationState { + stage: InstallationStage; + message: string; + actionUri: string; +} + +export interface Connection { + name: string; + createTime?: string; + updateTime?: string; + deleteTime?: string; + labels?: { + [key: string]: string; + }; + githubConfig?: GitHubConfig; + installationState: InstallationState; + disabled?: boolean; + reconciling?: boolean; + annotations?: { + [key: string]: string; + }; + etag?: string; + uid?: string; +} + +type ConnectionOutputOnlyFields = + | "createTime" + | "updateTime" + | "deleteTime" + | "installationState" + | "reconciling" + | "uid"; + +export interface GitRepositoryLink { + name: string; + cloneUri: string; + createTime: string; + updateTime: string; + deleteTime: string; + labels?: { + [key: string]: string; + }; + etag?: string; + reconciling: boolean; + annotations?: { + [key: string]: string; + }; + uid: string; +} + +type GitRepositoryLinkOutputOnlyFields = + | "createTime" + | "updateTime" + | "deleteTime" + | "reconciling" + | "uid"; + +export interface LinkableGitRepositories { + repositories: LinkableGitRepository[]; + nextPageToken: string; +} + +export interface LinkableGitRepository { + cloneUri: string; +} + +/** + * Creates a Developer Connect Connection. + */ +export async function createConnection( + projectId: string, + location: string, + connectionId: string, + githubConfig: GitHubConfig, +): Promise { + const config: GitHubConfig = { + ...githubConfig, + githubApp: "FIREBASE", + }; + const res = await client.post< + Omit, ConnectionOutputOnlyFields>, + Operation + >( + `projects/${projectId}/locations/${location}/connections`, + { + githubConfig: config, + }, + { queryParams: { connectionId } }, + ); + return res.body; +} + +/** + * Gets details of a single Developer Connect Connection. + */ +export async function getConnection( + projectId: string, + location: string, + connectionId: string, +): Promise { + const name = `projects/${projectId}/locations/${location}/connections/${connectionId}`; + const res = await client.get(name); + return res.body; +} + +/** + * List Developer Connect Connections + */ +export async function listConnections(projectId: string, location: string): Promise { + const conns: Connection[] = []; + const getNextPage = async (pageToken = ""): Promise => { + const res = await client.get<{ + connections: Connection[]; + nextPageToken?: string; + }>(`/projects/${projectId}/locations/${location}/connections`, { + queryParams: { + pageSize: PAGE_SIZE_MAX, + pageToken, + }, + }); + if (Array.isArray(res.body.connections)) { + conns.push(...res.body.connections); + } + if (res.body.nextPageToken) { + await getNextPage(res.body.nextPageToken); + } + }; + await getNextPage(); + return conns; +} + +/** + * Gets a list of repositories that can be added to the provided Connection. + */ +export async function fetchLinkableGitRepositories( + projectId: string, + location: string, + connectionId: string, + pageToken = "", + pageSize = 1000, +): Promise { + const name = `projects/${projectId}/locations/${location}/connections/${connectionId}:fetchLinkableRepositories`; + const res = await client.get(name, { + queryParams: { + pageSize, + pageToken, + }, + }); + + return res.body; +} + +/** + * Creates a GitRepositoryLink.Upon linking a Git Repository, Developer + * Connect will configure the Git Repository to send webhook events to + * Developer Connect. + */ +export async function createGitRepositoryLink( + projectId: string, + location: string, + connectionId: string, + gitRepositoryLinkId: string, + cloneUri: string, +): Promise { + const res = await client.post< + Omit, + Operation + >( + `projects/${projectId}/locations/${location}/connections/${connectionId}/gitRepositoryLinks`, + { cloneUri }, + { queryParams: { gitRepositoryLinkId } }, + ); + return res.body; +} + +/** + * Get details of a single GitRepositoryLink + */ +export async function getGitRepositoryLink( + projectId: string, + location: string, + connectionId: string, + gitRepositoryLinkId: string, +): Promise { + const name = `projects/${projectId}/locations/${location}/connections/${connectionId}/gitRepositoryLinks/${gitRepositoryLinkId}`; + const res = await client.get(name); + return res.body; +} + +/** + * Returns email associated with the Developer Connect Service Agent + */ +export function serviceAgentEmail(projectNumber: string): string { + return `service-${projectNumber}@${developerConnectP4SAOrigin}`; +} diff --git a/src/test/gcp/devconnect.spec.ts b/src/test/gcp/devconnect.spec.ts new file mode 100644 index 00000000000..1b1c86cca86 --- /dev/null +++ b/src/test/gcp/devconnect.spec.ts @@ -0,0 +1,70 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as devconnect from "../../gcp/devConnect"; + +describe("developer connect", () => { + let post: sinon.SinonStub; + let get: sinon.SinonStub; + + const projectId = "project"; + const location = "us-central1"; + const connectionId = "apphosting-connection"; + const connectionsRequestPath = `projects/${projectId}/locations/${location}/connections`; + + beforeEach(() => { + post = sinon.stub(devconnect.client, "post"); + get = sinon.stub(devconnect.client, "get"); + }); + + afterEach(() => { + post.restore(); + get.restore(); + }); + + describe("createConnection", () => { + it("ensures githubConfig is FIREBASE", async () => { + post.returns({ body: {} }); + await devconnect.createConnection(projectId, location, connectionId, {}); + + expect(post).to.be.calledWith( + connectionsRequestPath, + { githubConfig: { githubApp: "FIREBASE" } }, + { queryParams: { connectionId } }, + ); + }); + }); + + describe("listConnections", () => { + it("interates through all pages and returns a single list", async () => { + const firstConnection = { name: "conn1", installationState: { stage: "COMPLETE" } }; + const secondConnection = { name: "conn2", installationState: { stage: "COMPLETE" } }; + const thirdConnection = { name: "conn3", installationState: { stage: "COMPLETE" } }; + + get + .onFirstCall() + .returns({ + body: { + connections: [firstConnection], + nextPageToken: "someToken", + }, + }) + .onSecondCall() + .returns({ + body: { + connections: [secondConnection], + nextPageToken: "someToken2", + }, + }) + .onThirdCall() + .returns({ + body: { + connections: [thirdConnection], + }, + }); + + const conns = await devconnect.listConnections(projectId, location); + expect(get).callCount(3); + expect(conns).to.deep.equal([firstConnection, secondConnection, thirdConnection]); + }); + }); +});