Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App Hosting: add github branch validation #7371

Merged
merged 12 commits into from
Jul 8, 2024
50 changes: 50 additions & 0 deletions src/apphosting/githubConnections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,4 +395,54 @@ describe("githubConnections", () => {
expect(promptOnceStub).to.be.called;
});
});
describe("promptGitHubBranch", () => {
const sandbox: sinon.SinonSandbox = sinon.createSandbox();

let promptOnceStub: sinon.SinonStub;
let listAllBranchesStub: sinon.SinonStub;

beforeEach(() => {
promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call");
listAllBranchesStub = sandbox
.stub(devconnect, "listAllBranches")
.throws("Unexpected listAllBranches call");
});

afterEach(() => {
sandbox.verifyAndRestore();
});

it("prompts user for branch", async () => {
listAllBranchesStub.returns(new Set(["main", "test1"]));

promptOnceStub.onFirstCall().returns("main");
const testRepoLink = {
name: "test",
cloneUri: "/test",
createTime: "",
updateTime: "",
deleteTime: "",
reconciling: false,
uid: "",
};
await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("main");
});

it("re-prompts if user enters a branch that does not exist in given repo", async () => {
listAllBranchesStub.returns(new Set(["main", "test1"]));

promptOnceStub.onFirstCall().returns("not-main");
promptOnceStub.onSecondCall().returns("test1");
const testRepoLink = {
name: "test",
cloneUri: "/test",
createTime: "",
updateTime: "",
deleteTime: "",
reconciling: false,
uid: "",
};
await expect(repo.promptGitHubBranch(testRepoLink)).to.eventually.equal("test1");
});
});
});
24 changes: 24 additions & 0 deletions src/apphosting/githubConnections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
const APPHOSTING_CONN_PATTERN = /.+\/apphosting-github-conn-.+$/;
const APPHOSTING_OAUTH_CONN_NAME = "firebase-app-hosting-github-oauth";
const CONNECTION_NAME_REGEX =
/^projects\/(?<projectId>[^\/]+)\/locations\/(?<location>[^\/]+)\/connections\/(?<id>[^\/]+)$/;

Check warning on line 25 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unnecessary escape character: \/

Check warning on line 25 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unnecessary escape character: \/

Check warning on line 25 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unnecessary escape character: \/

/**
* Exported for unit testing.
Expand Down Expand Up @@ -127,7 +127,7 @@
} while (repoCloneUri === ADD_CONN_CHOICE);

// Ensure that the selected connection exists in the same region as the backend
const { id: connectionId } = parseConnectionName(connection.name)!;

Check warning on line 130 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
await getOrCreateConnection(projectId, location, connectionId, {
authorizerCredential: connection.githubConfig?.authorizerCredential,
appInstallationId: connection.githubConfig?.appInstallationId,
Expand Down Expand Up @@ -192,7 +192,7 @@
try {
conn = await devConnect.getConnection(projectId, location, APPHOSTING_OAUTH_CONN_NAME);
} catch (err: unknown) {
if ((err as any).status === 404) {

Check warning on line 195 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .status on an `any` value

Check warning on line 195 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
// Cloud build P4SA requires the secret manager admin role.
// This is required when creating an initial connection which is the Oauth connection in our case.
await ensureSecretManagerAdminGrant(projectId);
Expand All @@ -214,7 +214,7 @@
message: "Press Enter once you have authorized the GitHub App.",
});
cleanup();
const { projectId, location, id } = parseConnectionName(conn.name)!;

Check warning on line 217 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
conn = await devConnect.getConnection(projectId, location, id);
}
utils.logSuccess("Connected with GitHub successfully\n");
Expand All @@ -231,7 +231,7 @@
type: "autocomplete",
name: "cloneUri",
message: "Which GitHub repo do you want to deploy?",
source: (_: any, input = ""): Promise<(inquirer.DistinctChoice | inquirer.Separator)[]> => {

Check warning on line 234 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
return new Promise((resolve) =>
resolve([
new inquirer.Separator(),
Expand All @@ -257,6 +257,30 @@
return { cloneUri, connection: cloneUriToConnection[cloneUri] };
}

/**
* Prompts the user for a GitHub branch and validates that the given branch
* actually exists. User is re-prompted until they enter a valid branch.
*/
export async function promptGitHubBranch(repoLink: devConnect.GitRepositoryLink) {

Check warning on line 264 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const branches = await devConnect.listAllBranches(repoLink.name);
while (true) {

Check warning on line 266 in src/apphosting/githubConnections.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const branch = await promptOnce({
name: "branch",
type: "input",
default: "main",
message: "Pick a branch for continuous deployment",
});

if (branches.has(branch)) {
return branch;
}

utils.logWarning(
`The branch "${branch}" does not exist on "${extractRepoSlugFromUri(repoLink.cloneUri)}". Please enter a valid branch for this repo.`,
);
}
}

/**
* Exported for unit testing
*/
Expand Down
11 changes: 3 additions & 8 deletions src/apphosting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export async function doSetup(
location =
location || (await promptLocation(projectId, "Select a location to host your backend:\n"));

const gitRepositoryConnection: GitRepositoryLink = await githubConnections.linkGitHubRepository(
const gitRepositoryLink: GitRepositoryLink = await githubConnections.linkGitHubRepository(
projectId,
location,
);
Expand All @@ -112,12 +112,7 @@ export async function doSetup(
// TODO: Once tag patterns are implemented, prompt which method the user
// prefers. We could reduce the number of questions asked by letting people
// enter tag:<pattern>?
const branch = await promptOnce({
name: "branch",
type: "input",
default: "main",
message: "Pick a branch for continuous deployment",
});
const branch = await githubConnections.promptGitHubBranch(gitRepositoryLink);
logSuccess(`Repo linked successfully!\n`);

logBullet(`${clc.yellow("===")} Set up your backend`);
Expand All @@ -139,7 +134,7 @@ export async function doSetup(
projectId,
location,
backendId,
gitRepositoryConnection,
gitRepositoryLink,
serviceAccount,
webApp?.id,
rootDir,
Expand Down
33 changes: 33 additions & 0 deletions src/gcp/devConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,39 @@ export async function listAllLinkableGitRepositories(
return repos;
}

/**
* Lists all branches for a given repo. Returns a set of branches.
*/

export async function listAllBranches(repoLinkName: string): Promise<Set<string>> {
const branches = new Set<string>();

const getNextPage = async (pageToken = ""): Promise<void> => {
const res = await client.get<{
refNames: string[];
nextPageToken?: string;
}>(`${repoLinkName}:fetchGitRefs`, {
queryParams: {
refType: "BRANCH",
pageSize: PAGE_SIZE_MAX,
pageToken,
},
});
if (Array.isArray(res.body.refNames)) {
res.body.refNames.forEach((branch) => {
branches.add(branch);
});
}
if (res.body.nextPageToken) {
await getNextPage(res.body.nextPageToken);
}
};

await getNextPage();

return branches;
}

/**
* Creates a GitRepositoryLink.Upon linking a Git Repository, Developer
* Connect will configure the Git Repository to send webhook events to
Expand Down
37 changes: 37 additions & 0 deletions src/gcp/devconnect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,41 @@ describe("developer connect", () => {
expect(conns).to.deep.equal([firstRepo, secondRepo, thirdRepo]);
});
});

describe("listAllBranches", () => {
it("interates through all pages and returns a single list and map", async () => {
const firstBranch = "test";
const secondBranch = "test2";
const thirdBranch = "test3";

get
.onFirstCall()
.returns({
body: {
refNames: [firstBranch],
nextPageToken: "someToken",
},
})
.onSecondCall()
.returns({
body: {
refNames: [secondBranch],
nextPageToken: "someToken2",
},
})
.onThirdCall()
.returns({
body: {
refNames: [thirdBranch],
},
});

const branches = await devconnect.listAllBranches(
"/projects/blah/locations/us-central1/connections/blah",
);
expect(get).callCount(3);

expect(branches).to.deep.equal(new Set([firstBranch, secondBranch, thirdBranch]));
});
});
});
Loading