Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🚀 Feature: Drop end-user dependency on gh
### 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/template-typescript-node-package/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

The [GitHub CLI (`gh`)](https://cli.github.com) is really convenient. I use it a lot personally. Right now, `gh` is used for two things in the repo's hydration/setup scrips:

1. Getting a [GitHub auth token](https://docs.github.com/en/authentication) for the user: search `gh auth` in the code
2. Running commands to modify the repository: search `gh ` _(note the trailing space)_

However, not everyone using this template might be a `gh` user already. It's inconvenient that you'd have to use it. Let's remove the dependency on having that tool installed. I think that'll mean two changes:

1. For the auth token: try running the same `gh auth` scripts as before, and if they fail, fall back to a CLI flag like `--github-auth`
2. Switch to using [Octokit](https://github.com/octokit/octokit.js) instead of `gh ...` for any API calls

### Additional Info

_No response_

Pull request description:
feat: drop dependency on gh
<!-- 👋 Hi, thanks for sending a PR to create-typescript-app! 💖.
Please fill out all fields below and make sure each item is true and [x] checked.
Otherwise we may not be able to review your PR. -->

## PR Checklist

- [x] Addresses an existing open issue: fixes #667
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Replaces the end-user dependency on `gh` (the GitHub CLI) with more manual Octokit calls. It's a bit less convenient this way but is more type-safe and means users don't need to have some random GitHub utility installed to use the template.

Update December 2023: this works but I don't like how the user has to either have `gh` logged in or use a `process.env.GH_TOKEN`... I'd like to find the time to look into alternatives to log the user in.

Update August 2024: I don't want to procrastinate any more. This PR doesn't make things _worse_. So as long as error messages explicitly tell people to either log in with `gh` or set `process.env.GH_TOKEN`, this is fine.

Patch:
diff --git a/README.md b/README.md
index 3edd261..8106e39 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,9 @@ It includes options not just for building and testing but also GitHub repository
 
 First make sure you have the following installed:
 
-- [GitHub CLI](https://cli.github.com) _(you'll need to be logged in)_
 - [Node.js](https://nodejs.org)
 - [pnpm](https://pnpm.io)
+- _(optional, but helpful)_ [GitHub CLI](https://cli.github.com) _(you'll need to be logged in)_
 
 Then in an existing repository or in your directory where you'd like to make a new repository:
 
diff --git a/src/create/createWithOptions.ts b/src/create/createWithOptions.ts
index 295d8cb..6f60340 100644
--- a/src/create/createWithOptions.ts
+++ b/src/create/createWithOptions.ts
@@ -31,7 +31,7 @@ export async function createWithOptions({ github, options }: GitHubAndOptions) {
 
 	if (!options.excludeAllContributors && !options.skipAllContributorsApi) {
 		await withSpinner("Adding contributors to table", async () => {
-			await addToolAllContributors(options);
+			await addToolAllContributors(github?.octokit, options);
 		});
 	}
 
diff --git a/src/initialize/initializeWithOptions.ts b/src/initialize/initializeWithOptions.ts
index 2460a18..b3665af 100644
--- a/src/initialize/initializeWithOptions.ts
+++ b/src/initialize/initializeWithOptions.ts
@@ -41,7 +41,7 @@ export async function initializeWithOptions({
 
 	if (!options.excludeAllContributors) {
 		await withSpinner("Updating existing contributor details", async () => {
-			await addOwnerAsAllContributor(options);
+			await addOwnerAsAllContributor(github?.octokit, options);
 		});
 	}
 
diff --git a/src/shared/getGitHubUserAsAllContributor.ts b/src/shared/getGitHubUserAsAllContributor.ts
index a290dde..78e19c0 100644
--- a/src/shared/getGitHubUserAsAllContributor.ts
+++ b/src/shared/getGitHubUserAsAllContributor.ts
@@ -1,13 +1,11 @@
 import chalk from "chalk";
 import { $ } from "execa";
+import { Octokit } from "octokit";
 
 import { Options } from "./types.js";
 
-interface GhUserOutput {
-	login: string;
-}
-
 export async function getGitHubUserAsAllContributor(
+	octokit: Octokit | undefined,
 	options: Pick<Options, "offline" | "owner">,
 ) {
 	if (options.offline) {
@@ -21,14 +19,18 @@ export async function getGitHubUserAsAllContributor(
 
 	let user: string;
 
-	try {
-		user = (JSON.parse((await $`gh api user`).stdout) as GhUserOutput).login;
-	} catch {
-		console.warn(
-			chalk.gray(
-				`Couldn't authenticate GitHub user, falling back to the provided owner name '${options.owner}'.`,
-			),
-		);
+	if (octokit) {
+		try {
+			user = (await octokit.rest.users.getAuthenticated()).data.login;
+		} catch {
+			console.warn(
+				chalk.gray(
+					`Couldn't authenticate GitHub user, falling back to the provided owner name '${options.owner}'.`,
+				),
+			);
+			user = options.owner;
+		}
+	} else {
 		user = options.owner;
 	}
 
diff --git a/src/shared/options/createRepositoryWithApi.ts b/src/shared/options/createRepositoryWithApi.ts
index 1a7a5bd..f8be9b3 100644
--- a/src/shared/options/createRepositoryWithApi.ts
+++ b/src/shared/options/createRepositoryWithApi.ts
@@ -22,14 +22,20 @@ export async function createRepositoryWithApi(
 
 	const currentUser = await octokit.rest.users.getAuthenticated();
 
-	if (currentUser.data.login === options.owner) {
-		await octokit.rest.repos.createForAuthenticatedUser({
-			name: options.repository,
-		});
-	} else {
-		await octokit.rest.repos.createInOrg({
-			name: options.repository,
-			org: options.owner,
+	try {
+		if (currentUser.data.login === options.owner) {
+			await octokit.rest.repos.createForAuthenticatedUser({
+				name: options.repository,
+			});
+		} else {
+			await octokit.rest.repos.createInOrg({
+				name: options.repository,
+				org: options.owner,
+			});
+		}
+	} catch (error) {
+		throw new Error("Failed to create new repository on GitHub.", {
+			cause: error,
 		});
 	}
 }
diff --git a/src/shared/options/getGitHub.ts b/src/shared/options/getGitHub.ts
index 2a73795..6f3f573 100644
--- a/src/shared/options/getGitHub.ts
+++ b/src/shared/options/getGitHub.ts
@@ -10,9 +10,10 @@ export async function getGitHub(): Promise<GitHub | undefined> {
 	const auth = await getGitHubAuthToken();
 
 	if (!auth.succeeded) {
-		throw new Error("GitHub authentication failed.", {
-			cause: auth.error,
-		});
+		throw new Error(
+			"Couldn't authenticate with GitHub. Either log in with `gh auth login` (https://cli.github.com) or set a GH_TOKEN environment variable.",
+			{ cause: auth.error },
+		);
 	}
 
 	const octokit = new Octokit({ auth: auth.token });
diff --git a/src/steps/addOwnerAsAllContributor.ts b/src/steps/addOwnerAsAllContributor.ts
index 1b5ff7f..5789464 100644
--- a/src/steps/addOwnerAsAllContributor.ts
+++ b/src/steps/addOwnerAsAllContributor.ts
@@ -1,4 +1,5 @@
 import * as fs from "node:fs/promises";
+import { Octokit } from "octokit";
 
 import { getGitHubUserAsAllContributor } from "../shared/getGitHubUserAsAllContributor.js";
 import { readFileAsJson } from "../shared/readFileAsJson.js";
@@ -6,9 +7,10 @@ import { AllContributorsData, Options } from "../shared/types.js";
 import { formatJson } from "./writing/creation/formatters/formatJson.js";
 
 export async function addOwnerAsAllContributor(
+	octokit: Octokit | undefined,
 	options: Pick<Options, "offline" | "owner">,
 ) {
-	const user = await getGitHubUserAsAllContributor(options);
+	const user = await getGitHubUserAsAllContributor(octokit, options);
 
 	const existingContributors = (await readFileAsJson(
 		"./.all-contributorsrc",
diff --git a/src/steps/addToolAllContributors.ts b/src/steps/addToolAllContributors.ts
index 907d15d..dbf0dae 100644
--- a/src/steps/addToolAllContributors.ts
+++ b/src/steps/addToolAllContributors.ts
@@ -1,12 +1,14 @@
 import { $ } from "execa";
+import { Octokit } from "octokit";
 
 import { getGitHubUserAsAllContributor } from "../shared/getGitHubUserAsAllContributor.js";
 import { Options } from "../shared/types.js";
 
 export async function addToolAllContributors(
+	octokit: Octokit | undefined,
 	options: Pick<Options, "offline" | "owner">,
 ) {
-	const login = await getGitHubUserAsAllContributor(options);
+	const login = await getGitHubUserAsAllContributor(octokit, options);
 
 	if (login !== "JoshuaKGoldberg") {
 		await $`npx -y all-contributors-cli add JoshuaKGoldberg tool`;
diff --git a/src/steps/initializeGitHubRepository/index.ts b/src/steps/initializeGitHubRepository/index.ts
index 5298e71..51ed2a9 100644
--- a/src/steps/initializeGitHubRepository/index.ts
+++ b/src/steps/initializeGitHubRepository/index.ts
@@ -13,5 +13,5 @@ export async function initializeGitHubRepository(
 	await initializeGitRemote(options);
 	await initializeRepositorySettings(octokit, options);
 	await initializeBranchProtectionSettings(octokit, options);
-	await initializeRepositoryLabels();
+	await initializeRepositoryLabels(octokit, options);
 }
diff --git a/src/steps/initializeGitHubRepository/labels/getExistingEquivalentLabels.ts b/src/steps/initializeGitHubRepository/labels/getExistingEquivalentLabels.ts
index ab78a95..e884751 100644
--- a/src/steps/initializeGitHubRepository/labels/getExistingEquivalentLabels.ts
+++ b/src/steps/initializeGitHubRepository/labels/getExistingEquivalentLabels.ts
@@ -5,7 +5,7 @@ const aliases = new Map([
 
 export interface GhLabelData {
 	color: string;
-	description: string;
+	description: null | string;
 	name: string;
 }
 
diff --git a/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.ts b/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.ts
index 06b39dd..5c33570 100644
--- a/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.ts
+++ b/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.ts
@@ -1,15 +1,23 @@
-import { $ } from "execa";
+import { Octokit } from "octokit";
 
-import {
-	getExistingEquivalentLabels,
-	GhLabelData,
-} from "./getExistingEquivalentLabels.js";
+import { Options } from "../../../shared/types.js";
+import { getExistingEquivalentLabels } from "./getExistingEquivalentLabels.js";
 import { outcomeLabels } from "./outcomeLabels.js";
 
-export async function initializeRepositoryLabels() {
-	const existingLabels = JSON.parse(
-		(await $`gh label list --json color,description,name`).stdout || "[]",
-	) as GhLabelData[];
+export async function initializeRepositoryLabels(
+	octokit: Octokit,
+	options: Pick<Options, "owner" | "repository">,
+) {
+	const { data: existingLabels } = await octokit.request(
+		"GET /repos/{owner}/{repo}/labels",
+		{
+			headers: {
+				"X-GitHub-Api-Version": "2022-11-28",
+			},
+			owner: options.owner,
+			repo: options.repository,
+		},
+	);
 
 	for (const outcome of outcomeLabels) {
 		const existingEquivalents = getExistingEquivalentLabels(
@@ -19,7 +27,16 @@ export async function initializeRepositoryLabels() {
 
 		// Case: the repo has neither of the two label types
 		if (!existingEquivalents.length) {
-			await $`gh label create ${outcome.name} --color ${outcome.color} --description ${outcome.description}`;
+			await octokit.request("POST /repos/{owner}/{repo}/labels", {
+				color: outcome.color.replace("#", ""),
+				description: outcome.description,
+				headers: {
+					"X-GitHub-Api-Version": "2022-11-28",
+				},
+				name: outcome.name,
+				owner: options.owner,
+				repo: options.repository,
+			});
 			continue;
 		}
 
@@ -30,7 +47,15 @@ export async function initializeRepositoryLabels() {
 				existingEquivalent.name !== outcome.name &&
 				existingLabels.some((existing) => existing.name === outcome.name)
 			) {
-				await $`gh label delete ${existingEquivalent.name} --yes`;
+				await octokit.request("DELETE /repos/{owner}/{repo}/labels/{name}", {
+					headers: {
+						"X-GitHub-Api-Version": "2022-11-28",
+					},
+					name: existingEquivalent.name,
+					owner: options.owner,
+					repo: options.repository,
+				});
+
 				continue;
 			}
 
@@ -42,7 +67,17 @@ export async function initializeRepositoryLabels() {
 				outcome.description !== existingEquivalent.description ||
 				outcome.name !== existingEquivalent.name
 			) {
-				await $`gh label edit ${existingEquivalent.name} --color ${outcome.color} --description ${outcome.description} --name ${outcome.name}`;
+				await octokit.request("PATCH /repos/{owner}/{repo}/labels/{name}", {
+					color: outcome.color.replace("#", ""),
+					description: outcome.description,
+					headers: {
+						"X-GitHub-Api-Version": "2022-11-28",
+					},
+					name: existingEquivalent.name,
+					new_name: outcome.name,
+					owner: options.owner,
+					repo: options.repository,
+				});
 			}
 		}
 	}


Test patch:
diff --git a/src/create/createWithOptions.test.ts b/src/create/createWithOptions.test.ts
index 1c8b535..7df6058 100644
--- a/src/create/createWithOptions.test.ts
+++ b/src/create/createWithOptions.test.ts
@@ -93,7 +93,10 @@ describe("createWithOptions", () => {
 		};
 
 		await createWithOptions({ github, options });
-		expect(addToolAllContributors).toHaveBeenCalledWith(options);
+		expect(addToolAllContributors).toHaveBeenCalledWith(
+			github.octokit,
+			options,
+		);
 	});
 
 	it("does not call addToolAllContributors when excludeAllContributors is true", async () => {
diff --git a/src/initialize/initializeWithOptions.test.ts b/src/initialize/initializeWithOptions.test.ts
index 5cdd120..38ff828 100644
--- a/src/initialize/initializeWithOptions.test.ts
+++ b/src/initialize/initializeWithOptions.test.ts
@@ -81,7 +81,10 @@ describe("initializeWithOptions", () => {
 			options,
 		});
 
-		expect(mockAddOwnerAsAllContributor).toHaveBeenCalledWith(options);
+		expect(mockAddOwnerAsAllContributor).toHaveBeenCalledWith(
+			undefined,
+			options,
+		);
 	});
 
 	it("does not run addOwnerAsAllContributor when excludeAllContributors is true", async () => {
diff --git a/src/shared/getGitHubUserAsAllContributor.test.ts b/src/shared/getGitHubUserAsAllContributor.test.ts
index c0300f4..2bad543 100644
--- a/src/shared/getGitHubUserAsAllContributor.test.ts
+++ b/src/shared/getGitHubUserAsAllContributor.test.ts
@@ -1,4 +1,5 @@
 import chalk from "chalk";
+import { Octokit } from "octokit";
 import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest";
 
 import { getGitHubUserAsAllContributor } from "./getGitHubUserAsAllContributor.js";
@@ -13,6 +14,11 @@ vi.mock("execa", () => ({
 
 let mockConsoleWarn: MockInstance;
 
+const createMockOctokit = (getAuthenticated: MockInstance = vi.fn()) =>
+	({
+		rest: { users: { getAuthenticated } },
+	}) as unknown as Octokit;
+
 const owner = "TestOwner";
 
 describe("getGitHubUserAsAllContributor", () => {
@@ -23,7 +29,8 @@ describe("getGitHubUserAsAllContributor", () => {
 	});
 
 	it("defaults to owner with a log when options.offline is true", async () => {
-		const actual = await getGitHubUserAsAllContributor({
+		const octokit = createMockOctokit();
+		const actual = await getGitHubUserAsAllContributor(octokit, {
 			offline: true,
 			owner,
 		});
@@ -36,23 +43,24 @@ describe("getGitHubUserAsAllContributor", () => {
 		);
 	});
 
+	it("defaults to owner without a log when octokit is undefined", async () => {
+		const actual = await getGitHubUserAsAllContributor(undefined, { owner });
+
+		expect(actual).toEqual(owner);
+		expect(mockConsoleWarn).not.toHaveBeenCalled();
+	});
+
 	it("uses the user from gh api user when it succeeds", async () => {
 		const login = "gh-api-user";
+		const octokit = createMockOctokit(
+			vi.fn().mockResolvedValue({ data: { login } }),
+		);
 
-		mock$.mockResolvedValueOnce({
-			stdout: JSON.stringify({ login }),
-		});
-
-		await getGitHubUserAsAllContributor({ owner });
+		await getGitHubUserAsAllContributor(octokit, { owner });
 
 		expect(mockConsoleWarn).not.toHaveBeenCalled();
 		expect(mock$.mock.calls).toMatchInlineSnapshot(`
 			[
-			  [
-			    [
-			      "gh api user",
-			    ],
-			  ],
 			  [
 			    [
 			      "npx -y all-contributors-cli@6.25 add ",
@@ -67,9 +75,11 @@ describe("getGitHubUserAsAllContributor", () => {
 	});
 
 	it("defaults the user to the owner when gh api user fails", async () => {
-		mock$.mockRejectedValueOnce({});
+		const octokit = createMockOctokit(
+			vi.fn().mockRejectedValue(new Error("Oh no!")),
+		);
 
-		await getGitHubUserAsAllContributor({ owner });
+		await getGitHubUserAsAllContributor(octokit, { owner });
 
 		expect(mockConsoleWarn).toHaveBeenCalledWith(
 			chalk.gray(
@@ -78,11 +88,6 @@ describe("getGitHubUserAsAllContributor", () => {
 		);
 		expect(mock$.mock.calls).toMatchInlineSnapshot(`
 			[
-			  [
-			    [
-			      "gh api user",
-			    ],
-			  ],
 			  [
 			    [
 			      "npx -y all-contributors-cli@6.25 add ",
diff --git a/src/shared/options/getGitHub.test.ts b/src/shared/options/getGitHub.test.ts
index 3cbe3a2..01dcf79 100644
--- a/src/shared/options/getGitHub.test.ts
+++ b/src/shared/options/getGitHub.test.ts
@@ -32,7 +32,7 @@ describe("getOctokit", () => {
 		});
 
 		await expect(getGitHub).rejects.toMatchInlineSnapshot(
-			"[Error: GitHub authentication failed.]",
+			`[Error: Couldn't authenticate with GitHub. Either log in with \`gh auth login\` (https://cli.github.com) or set a GH_TOKEN environment variable.]`,
 		);
 	});
 
diff --git a/src/steps/addOwnerAsAllContributor.test.ts b/src/steps/addOwnerAsAllContributor.test.ts
index ba62f85..ec2d23b 100644
--- a/src/steps/addOwnerAsAllContributor.test.ts
+++ b/src/steps/addOwnerAsAllContributor.test.ts
@@ -41,7 +41,7 @@ describe("addOwnerAsAllContributor", () => {
 		mockReadFileAsJson.mockResolvedValue("invalid");
 
 		await expect(async () => {
-			await addOwnerAsAllContributor({ owner: mockOwner });
+			await addOwnerAsAllContributor(undefined, { owner: mockOwner });
 		}).rejects.toMatchInlineSnapshot(
 			'[Error: Invalid .all-contributorsrc: "invalid"]',
 		);
@@ -54,7 +54,7 @@ describe("addOwnerAsAllContributor", () => {
 		mockReadFileAsJson.mockResolvedValue({});
 
 		await expect(async () => {
-			await addOwnerAsAllContributor({ owner: mockOwner });
+			await addOwnerAsAllContributor(undefined, { owner: mockOwner });
 		}).rejects.toMatchInlineSnapshot(
 			"[Error: Invalid .all-contributorsrc: {}]",
 		);
@@ -68,7 +68,7 @@ describe("addOwnerAsAllContributor", () => {
 			contributors: [],
 		});
 
-		await addOwnerAsAllContributor({ owner: mockOwner });
+		await addOwnerAsAllContributor(undefined, { owner: mockOwner });
 
 		expect(mockWriteFile).toHaveBeenCalledWith(
 			"./.all-contributorsrc",
@@ -89,7 +89,7 @@ describe("addOwnerAsAllContributor", () => {
 			],
 		});
 
-		await addOwnerAsAllContributor({ owner: mockOwner });
+		await addOwnerAsAllContributor(undefined, { owner: mockOwner });
 
 		expect(mockWriteFile).toHaveBeenCalledWith(
 			"./.all-contributorsrc",
diff --git a/src/steps/addToolAllContributors.test.ts b/src/steps/addToolAllContributors.test.ts
index 5ddedcb..2aab649 100644
--- a/src/steps/addToolAllContributors.test.ts
+++ b/src/steps/addToolAllContributors.test.ts
@@ -22,7 +22,7 @@ describe("addToolAllContributors", () => {
 	it("adds JoshuaKGoldberg when that is not the current github user", async () => {
 		mockGetGitHubUserAsAllContributor.mockResolvedValue("JoshuaKGoldberg");
 
-		await addToolAllContributors({ owner: "owner" });
+		await addToolAllContributors(undefined, { owner: "owner" });
 
 		expect(mock$).not.toHaveBeenCalled();
 	});
@@ -30,7 +30,7 @@ describe("addToolAllContributors", () => {
 	it("does not add JoshuaKGoldberg when that not the current github user", async () => {
 		mockGetGitHubUserAsAllContributor.mockResolvedValue("other");
 
-		await addToolAllContributors({ owner: "owner" });
+		await addToolAllContributors(undefined, { owner: "owner" });
 
 		expect(mock$).toHaveBeenCalledWith([
 			`npx -y all-contributors-cli add JoshuaKGoldberg tool`,
diff --git a/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.test.ts b/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.test.ts
index c750abd..644fada 100644
--- a/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.test.ts
+++ b/src/steps/initializeGitHubRepository/labels/initializeRepositoryLabels.test.ts
@@ -1,15 +1,9 @@
-import { describe, expect, it, vi } from "vitest";
+import { Octokit } from "octokit";
+import { describe, expect, it, MockInstance, vi } from "vitest";
 
+import { GhLabelData } from "./getExistingEquivalentLabels.js";
 import { initializeRepositoryLabels } from "./initializeRepositoryLabels.js";
 
-const mock$ = vi.fn();
-
-vi.mock("execa", () => ({
-	get $() {
-		return mock$;
-	},
-}));
-
 const mockOutcomeLabel = {
 	color: "000000",
 	description: "def ghi",
@@ -22,340 +16,370 @@ vi.mock("./outcomeLabels.js", () => ({
 	},
 }));
 
+const createMockOctokit = (existingLabels: GhLabelData[]) =>
+	({
+		request: vi.fn().mockResolvedValueOnce({ data: existingLabels }),
+	}) as unknown as { request: MockInstance } & Octokit;
+
+const options = {
+	owner: "TestOwner",
+	repository: "test-repository",
+};
+
 describe("migrateRepositoryLabels", () => {
-	it("creates an outcome label when labels stdout is empty", async () => {
-		mock$.mockResolvedValue({
-			stdout: "",
-		});
+	it("creates an outcome label when there are no existing labels", async () => {
+		const octokit = createMockOctokit([]);
 
-		await initializeRepositoryLabels();
+		await initializeRepositoryLabels(octokit, options);
 
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label create ",
-			      " --color ",
-			      " --description ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
+			    "POST /repos/{owner}/{repo}/labels",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
-	it("creates an outcome label when it doesn't already exist", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "other",
-				},
-			]),
-		});
+	it("creates a new outcome label when an existing label doesn't have an equivalent", async () => {
+		const octokit = createMockOctokit([
+			{
+				color: "111111",
+				description: "jkl mno",
+				name: "other",
+			},
+		]);
 
-		await initializeRepositoryLabels();
+		await initializeRepositoryLabels(octokit, options);
 
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label create ",
-			      " --color ",
-			      " --description ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
+			    "POST /repos/{owner}/{repo}/labels",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("doesn't edit a outcome label when it already exists with the same information", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([mockOutcomeLabel]),
-		});
+		const octokit = createMockOctokit([mockOutcomeLabel]);
 
-		await initializeRepositoryLabels();
+		await initializeRepositoryLabels(octokit, options);
 
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("edits the outcome label when it already exists with different color", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					...mockOutcomeLabel,
-					color: "111111",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		const octokit = createMockOctokit([
+			{
+				...mockOutcomeLabel,
+				color: "111111",
+			},
+		]);
+
+		await initializeRepositoryLabels(octokit, options);
+
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label edit ",
-			      " --color ",
-			      " --description ",
-			      " --name ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
-			    "area: abc",
+			    "PATCH /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "new_name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("edits the outcome label when it already exists with a different description", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					...mockOutcomeLabel,
-					description: "updated",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		const octokit = createMockOctokit([
+			{
+				...mockOutcomeLabel,
+				description: "updated",
+			},
+		]);
+
+		await initializeRepositoryLabels(octokit, options);
+
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label edit ",
-			      " --color ",
-			      " --description ",
-			      " --name ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
-			    "area: abc",
+			    "PATCH /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "new_name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("deletes an existing non-outcome label when the equivalent outcome label already exists", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "area: abc",
-				},
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "area: abc",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		const octokit = createMockOctokit([
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "abc",
+			},
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "area: abc",
+			},
+		]);
+
+		await initializeRepositoryLabels(octokit, options);
+
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
+			  ],
+			  [
+			    "DELETE /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
-	it("deletes a pre-existing label when it isn't a outcome label", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "unknown",
-				},
-			]),
-		});
+	it("doesn't delete a pre-existing label when it isn't a outcome label", async () => {
+		const octokit = createMockOctokit([
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "unknown",
+			},
+		]);
 
-		await initializeRepositoryLabels();
+		await initializeRepositoryLabels(octokit, options);
 
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label create ",
-			      " --color ",
-			      " --description ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
+			    "POST /repos/{owner}/{repo}/labels",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("deletes the existing duplicate outcome label and edits the label with the outcome name and different color when both exist", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "abc",
-				},
-				{
-					color: "111111",
-					description: "def ghi",
-					name: "area: abc",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		const octokit = createMockOctokit([
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "abc",
+			},
+			{
+				color: "111111",
+				description: "def ghi",
+				name: "area: abc",
+			},
+		]);
+
+		await initializeRepositoryLabels(octokit, options);
+
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label delete ",
-			      " --yes",
-			    ],
-			    "abc",
+			    "DELETE /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label edit ",
-			      " --color ",
-			      " --description ",
-			      " --name ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
-			    "area: abc",
+			    "PATCH /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "color": "000000",
+			      "description": "def ghi",
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "area: abc",
+			      "new_name": "area: abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);
 	});
 
 	it("deletes the existing duplicate outcome label and does not edit the label with the outcome name and same information when both exist", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "abc",
-				},
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "area: abc",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
-			[
-			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
-			  ],
-			  [
-			    [
-			      "gh label delete ",
-			      " --yes",
-			    ],
-			    "abc",
-			  ],
-			]
-		`);
-	});
-
-	it("doesn't delete a pre-existing label when it isn't a outcome label", async () => {
-		mock$.mockResolvedValue({
-			stdout: JSON.stringify([
-				{
-					color: "000000",
-					description: "def ghi",
-					name: "jkl",
-				},
-			]),
-		});
-
-		await initializeRepositoryLabels();
-
-		expect(mock$.mock.calls).toMatchInlineSnapshot(`
+		const octokit = createMockOctokit([
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "abc",
+			},
+			{
+				color: "000000",
+				description: "def ghi",
+				name: "area: abc",
+			},
+		]);
+
+		await initializeRepositoryLabels(octokit, options);
+
+		expect(octokit.request.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
-			    [
-			      "gh label list --json color,description,name",
-			    ],
+			    "GET /repos/{owner}/{repo}/labels",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			  [
-			    [
-			      "gh label create ",
-			      " --color ",
-			      " --description ",
-			      "",
-			    ],
-			    "area: abc",
-			    "000000",
-			    "def ghi",
+			    "DELETE /repos/{owner}/{repo}/labels/{name}",
+			    {
+			      "headers": {
+			        "X-GitHub-Api-Version": "2022-11-28",
+			      },
+			      "name": "abc",
+			      "owner": "TestOwner",
+			      "repo": "test-repository",
+			    },
 			  ],
 			]
 		`);