Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🐛 Bug: Repositories considered created while GitHub is still populating labels
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Expected

Running `npx create-typescript-app@beta` with a logged-in `gh` user and valid data should not encounter any errors.

### Actual

I still sometimes see:

```plaintext
■  The repository-labels request failed. You should re-run it and fix its complaints.
│  HttpError: Validation Failed: {"resource":"Label","code":"already_exists","field":"name"} - https://docs.github.com/rest/issues/labels#create-a-label
│      at fetchWrapper (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/@octokit+request@9.2.2/node_modules/@octokit/request/dist-bundle/index.js:122:11)
│      at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
│      at async requestWithGraphqlErrorHandling (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/@octokit+plugin-retry@7.1.4_@octokit+core@6.1.4/node_modules/@octokit/plugin-retry/dist-bundle/index.js:36:20)
│      at async Job.doExecute (/Users/josh/repos/create-typescript-app/node_modules/.pnpm/bottleneck@2.19.5/node_modules/bottleneck/light.js:405:18)
│
```

### Additional Info

This looks a lot like https://github.com/JoshuaKGoldberg/bingo/issues/243. My hunch is the fix in https://github.com/JoshuaKGoldberg/bingo/pull/244 didn't fully fix things: that labels are themselves made asynchronously, so just waiting for them to start to be populated isn't enough.

🎁 

Pull request description:
fix(bingo): wait for repository to finish populating labels
## PR Checklist

- [x] Addresses an existing open issue: fixes #251
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Before, repo creation logic just wait for any labels to exist. But that's not enough: labels are created asynchronously.

Now, we wait until the number of labels is "stable": non-zero and equal to the past response two times in a row.

I couldn't find a dedicated API for this or better way to check, so I asked on social media: https://bsky.app/profile/joshuakgoldberg.com/post/3ljpmgtlmy224 & https://fosstodon.org/@JoshuaKGoldberg/114115757105227398. If anybody suggests one I'd happily take a followup issue.

💝 

Patch:
diff --git a/packages/bingo/src/cli/setup/createRepositoryOnGitHub.ts b/packages/bingo/src/cli/setup/createRepositoryOnGitHub.ts
index 7ca9b80..17d87f4 100644
--- a/packages/bingo/src/cli/setup/createRepositoryOnGitHub.ts
+++ b/packages/bingo/src/cli/setup/createRepositoryOnGitHub.ts
@@ -33,16 +33,34 @@ export async function createRepositoryOnGitHub(
 	// There's no built-in API to determine whether initialization is done,
 	// but we can approximate that by polling for whether labels are created.
 	// https://github.com/JoshuaKGoldberg/bingo/issues/243
-	// On the off chance the repository's organization has no default labels,
-	// we only retry up to 10 times.
-	for (let i = 0; i < 10; i += 1) {
-		const response = await octokit.request("GET /repos/{owner}/{repo}/labels", {
-			owner: target.owner,
-			repo: target.repository,
-		});
+	let knownLabelsLength = 0;
+
+	// Labels aren't all created at once: GitHub populates them asynchronously.
+	// We need to wait until the number of labels stops changing.
+	// https://github.com/JoshuaKGoldberg/bingo/issues/251
+	let matchedLabelsLength = false;
 
-		if (response.data.length) {
-			break;
+	// We limit retries case of GitHub slowness or an org with no default labels.
+	// Initial testing on a fast network found the maximum needed to be ~20-25.
+	for (let i = 0; i < 35; i += 1) {
+		const { data: labels } = await octokit.request(
+			"GET /repos/{owner}/{repo}/labels",
+			{
+				owner: target.owner,
+				repo: target.repository,
+			},
+		);
+
+		if (knownLabelsLength && labels.length === knownLabelsLength) {
+			if (matchedLabelsLength) {
+				return;
+			}
+
+			matchedLabelsLength = true;
+			continue;
 		}
+
+		knownLabelsLength = labels.length;
+		matchedLabelsLength = false;
 	}
 }


Test patch:
diff --git a/packages/bingo/src/cli/setup/createRepositoryOnGitHub.test.ts b/packages/bingo/src/cli/setup/createRepositoryOnGitHub.test.ts
index 8ac7da6..c620128 100644
--- a/packages/bingo/src/cli/setup/createRepositoryOnGitHub.test.ts
+++ b/packages/bingo/src/cli/setup/createRepositoryOnGitHub.test.ts
@@ -28,7 +28,7 @@ const createMockOctokit = () =>
 
 describe("createRepositoryOnGitHub", () => {
 	it("creates using a template when one is provided", async () => {
-		mockRequest.mockResolvedValueOnce({ data: [{}] });
+		mockRequest.mockResolvedValue({ data: [{}] });
 
 		const template = {
 			owner: "JoshuaKGoldberg",
@@ -53,7 +53,7 @@ describe("createRepositoryOnGitHub", () => {
 				login: options.owner,
 			},
 		});
-		mockRequest.mockResolvedValueOnce({ data: [{}] });
+		mockRequest.mockResolvedValue({ data: [{}] });
 
 		await createRepositoryOnGitHub(options, createMockOctokit());
 
@@ -67,7 +67,7 @@ describe("createRepositoryOnGitHub", () => {
 	it("creates under an org when the user is not the owner", async () => {
 		const login = "other-user";
 		mockGetAuthenticated.mockResolvedValueOnce({ data: { login } });
-		mockRequest.mockResolvedValueOnce({ data: [{}] });
+		mockRequest.mockResolvedValue({ data: [{}] });
 
 		await createRepositoryOnGitHub(options, createMockOctokit());
 
@@ -79,24 +79,40 @@ describe("createRepositoryOnGitHub", () => {
 		expect(mockCreateUsingTemplate).not.toHaveBeenCalled();
 	});
 
-	it("delays resolving until labels exist when the repository does not have labels for fewer than 10 calls", async () => {
+	it("resolves only after labels have the same length thrice in a row when the repository does not have labels for fewer than 35 calls", async () => {
 		mockGetAuthenticated.mockResolvedValueOnce({ data: {} });
 		mockRequest
 			.mockResolvedValueOnce({ data: [] })
 			.mockResolvedValueOnce({ data: [] })
+			.mockResolvedValueOnce({ data: [{}] })
+			.mockResolvedValueOnce({ data: [{}] })
 			.mockResolvedValueOnce({ data: [{}] });
 
 		await createRepositoryOnGitHub(options, createMockOctokit());
 
-		expect(mockRequest).toHaveBeenCalledTimes(3);
+		expect(mockRequest).toHaveBeenCalledTimes(5);
 	});
 
-	it("resolves after 10 retries when the repository does not have labels for 10 calls", async () => {
+	it("resolves after 35 retries when the repository does not have labels for 35 calls", async () => {
 		mockGetAuthenticated.mockResolvedValueOnce({ data: {} });
 		mockRequest.mockResolvedValue({ data: [] });
 
 		await createRepositoryOnGitHub(options, createMockOctokit());
 
-		expect(mockRequest).toHaveBeenCalledTimes(10);
+		expect(mockRequest).toHaveBeenCalledTimes(35);
+	});
+
+	it("resolves after 35 retries when the repository labels keep increasing in count for 35 calls", async () => {
+		let callCount = 0;
+
+		mockGetAuthenticated.mockResolvedValueOnce({ data: {} });
+		mockRequest.mockImplementation(() => {
+			callCount += 1;
+			return Promise.resolve({ data: new Array(callCount).map(() => ({})) });
+		});
+
+		await createRepositoryOnGitHub(options, createMockOctokit());
+
+		expect(mockRequest).toHaveBeenCalledTimes(35);
 	});
 });