Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🚀 Feature: Switch request Creations to a serializable and testable format
### Bug Report Checklist

- [X] I have pulled the latest `main` branch of the repository.
- [X] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

I'd noted in #64:

> Note that this is the first Creation form that isn't directly serializable. _Files_ and _scripts_ can all be made JSON; _requests_ now introduce functions. I'd been hoping to keep everything serializable - but being able to instead get nice type completion with using Octokit APIs is just more useful.
> 
> If someone wants serializable requests in the future, I suppose they could file a feature request asking for the JSON-like equivalent of this. Something like:
> 
> ```ts
> {
>   "fetcher": "octokit",
>   "method": ["rest", "repos", "update"],
>   "send": {
>     allow_auto_merge: true,
>     // ...
>   }
> }
> ```
> 
> ...where fancy TypeScript types determine the `send` data to be `Parameters<typeof octokit.rest.repos.update>[0]`. That's a lot more work. Perhaps a future improvement.
> 
> I also don't love that explicit `id` in there. Previous Creations are auto-mergable without them; this one is manual. I suppose that's also tied in with it not being serializable. Perhaps a future improvement.

Another downside of the non-serializable `requests` format is the not-very-informative `[Function]` showing up in creation snapshots. For example, in working on https://github.com/JoshuaKGoldberg/create-typescript-app/issues/1775, I'm seeing the test output for a Block that creates `requests` as:

```ts
const creation = testBlock(blockRepositoryBranchProtection, {
	addons: {
		requiredStatusChecks: ["build", "test"],
	},
	options: optionsBase,
});

expect(creation).toMatchInlineSnapshot(`
	{
	  "requests": [
	    {
	      "id": "branch-protection",
	      "send": [Function],
	    },
	  ],
	}
`);
```

As a followup, I'd like to eventually pursue the avenue of a fully serializable, type-safe `requests` format.

### Additional Info

Marking as `status: blocked` for the time being. It doesn't block full CTA parity. But I will want it to block a full launch.

💖 

Pull request:
feat: switch created requests to serializable objects
## PR Checklist

- [x] Addresses an existing open issue: fixes #65
- [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

Adds a new `bingo-requests` package that describes two kinds of created requests:

* `CreatedFetchRequest`: not very typed
* `CreatedOctokitRequest`: rather typed, using Octokit types

💝 

Patch:
diff --git a/packages/bingo-requests/README.md b/packages/bingo-requests/README.md
new file mode 100644
index 0000000..be9f71a
--- /dev/null
+++ b/packages/bingo-requests/README.md
@@ -0,0 +1,15 @@
+<h1 align="center">bingo-fs</h1>
+
+<p align="center">
+	The virtual file system used by <a href="https://create.bingo">Bingo</a>. 🗄️
+</p>
+
+<p align="center">
+	<a href="https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CODE_OF_CONDUCT.md" target="_blank"><img alt="🤝 Code of Conduct: Kept" src="https://img.shields.io/badge/%F0%9F%A4%9D_code_of_conduct-kept-21bb42" /></a>
+	<a href="https://codecov.io/gh/JoshuaKGoldberg/bingo" target="_blank"><img alt="🧪 Coverage" src="https://img.shields.io/codecov/c/github/JoshuaKGoldberg/bingo?label=%F0%9F%A7%AA%20coverage" /></a>
+	<a href="https://github.com/JoshuaKGoldberg/bingo/blob/main/LICENSE.md" target="_blank"><img alt="📝 License: MIT" src="https://img.shields.io/badge/%F0%9F%93%9D_license-MIT-21bb42.svg"></a>
+	<a href="http://npmjs.com/package/bingo-testers"><img alt="📦 npm version" src="https://img.shields.io/npm/v/bingo-testers?color=21bb42&label=%F0%9F%93%A6%20npm" /></a>
+	<img alt="💪 TypeScript: Strict" src="https://img.shields.io/badge/%F0%9F%92%AA_typescript-strict-21bb42.svg" />
+</p>
+
+See **[create.bingo > Building Templates > Packages > bingo-fs](https://create.bingo)** for documentation.
diff --git a/packages/bingo-requests/package.json b/packages/bingo-requests/package.json
new file mode 100644
index 0000000..0df8c60
--- /dev/null
+++ b/packages/bingo-requests/package.json
@@ -0,0 +1,33 @@
+{
+	"name": "bingo-requests",
+	"version": "0.5.4",
+	"description": "Abstracted network requests as used by Bingo. 📠",
+	"homepage": "https://www.create.bingo/build/packages/bingo-requests",
+	"repository": {
+		"type": "git",
+		"url": "https://github.com/JoshuaKGoldberg/bingo",
+		"directory": "packages/bingo-requests"
+	},
+	"license": "MIT",
+	"author": {
+		"name": "Josh Goldberg ✨",
+		"email": "npm@joshuakgoldberg.com"
+	},
+	"type": "module",
+	"main": "./lib/index.js",
+	"files": [
+		"lib/",
+		"package.json",
+		"LICENSE.md",
+		"README.md"
+	],
+	"scripts": {
+		"build": "tsc"
+	},
+	"dependencies": {
+		"@octokit/types": "^13.8.0"
+	},
+	"engines": {
+		"node": ">=18"
+	}
+}
diff --git a/packages/bingo-requests/src/index.ts b/packages/bingo-requests/src/index.ts
new file mode 100644
index 0000000..2e7353e
--- /dev/null
+++ b/packages/bingo-requests/src/index.ts
@@ -0,0 +1,1 @@
+export type * from "./types.js";
diff --git a/packages/bingo-requests/src/types.ts b/packages/bingo-requests/src/types.ts
new file mode 100644
index 0000000..2b9afe3
--- /dev/null
+++ b/packages/bingo-requests/src/types.ts
@@ -0,0 +1,52 @@
+import { Endpoints, RequestParameters } from "@octokit/types";
+
+/**
+ * Request to be send with fetch().
+ */
+export interface CreatedFetchRequest {
+	/**
+	 * Unique ID for logging and to differentiate between requests to the same url.
+	 */
+	id?: string;
+
+	/**
+	 * Any options to be sent as the second parameter to fetch().
+	 */
+	init?: RequestInit;
+
+	type: "fetch";
+
+	/**
+	 * URL to use as the first parameter to fetch().
+	 */
+	url: string;
+}
+
+/**
+ * Request to be send with GitHub's Octokit to an API endpoint.
+ * @template TargetEndpoint GitHub API endpoint, such as `POST /repos/{owner}/{repo}/labels`.
+ */
+export interface CreatedOctokitRequest<
+	TargetEndpoint extends GitHubEndpoint = GitHubEndpoint,
+> {
+	/**
+	 * Octokit endpoint to send to.
+	 */
+	endpoint: TargetEndpoint;
+
+	/**
+	 * Unique ID for logging and to differentiate between requests to the same endpoint.
+	 */
+	id?: string;
+
+	/**
+	 * Parameter data to attach to the request.
+	 */
+	parameters: Endpoints[TargetEndpoint]["parameters"] & RequestParameters;
+
+	type: "octokit";
+}
+
+export type CreatedRequest = CreatedFetchRequest | CreatedOctokitRequest;
+
+export type GitHubEndpoint = keyof Endpoints;
diff --git a/packages/bingo-requests/tsconfig.json b/packages/bingo-requests/tsconfig.json
new file mode 100644
index 0000000..de061cb
--- /dev/null
+++ b/packages/bingo-requests/tsconfig.json
@@ -0,0 +1,8 @@
+{
+	"compilerOptions": {
+		"outDir": "lib",
+		"rootDir": "src"
+	},
+	"extends": "../../tsconfig.base.json",
+	"include": ["src"]
+}
diff --git a/packages/bingo/package.json b/packages/bingo/package.json
index c704c0b..a397ceb 100644
--- a/packages/bingo/package.json
+++ b/packages/bingo/package.json
@@ -28,6 +28,7 @@
 	"dependencies": {
 		"@clack/prompts": "^0.10.0",
 		"bingo-fs": "workspace:^",
+		"bingo-requests": "workspace:^",
 		"bingo-systems": "workspace:^",
 		"cached-factory": "^0.1.0",
 		"call-id": "^0.1.0",
diff --git a/packages/bingo/src/mergers/mergeFetchRequests.ts b/packages/bingo/src/mergers/mergeFetchRequests.ts
new file mode 100644
index 0000000..c4de90b
--- /dev/null
+++ b/packages/bingo/src/mergers/mergeFetchRequests.ts
@@ -0,0 +1,32 @@
+import { CreatedFetchRequest } from "bingo-requests";
+import { withoutUndefinedProperties } from "without-undefined-properties";
+
+export function mergeFetchRequests(
+	allExisting: CreatedFetchRequest[] | undefined,
+	added: CreatedFetchRequest,
+): CreatedFetchRequest[] {
+	if (!allExisting) {
+		return [added];
+	}
+
+	for (let i = 0; i < allExisting.length; i++) {
+		const existing = allExisting[i];
+		if (existing.id !== added.id || existing.url !== added.url) {
+			continue;
+		}
+
+		const merged: typeof existing = withoutUndefinedProperties({
+			...existing,
+			init:
+				added.init || existing.init
+					? Object.assign({}, existing.init, added.init)
+					: undefined,
+		});
+
+		allExisting[i] = merged;
+
+		return allExisting;
+	}
+
+	return [...allExisting, added];
+}
diff --git a/packages/bingo/src/mergers/mergeOctokitRequests.ts b/packages/bingo/src/mergers/mergeOctokitRequests.ts
new file mode 100644
index 0000000..34ccbfb
--- /dev/null
+++ b/packages/bingo/src/mergers/mergeOctokitRequests.ts
@@ -0,0 +1,29 @@
+import { CreatedOctokitRequest } from "bingo-requests";
+import { withoutUndefinedProperties } from "without-undefined-properties";
+
+export function mergeOctokitRequests(
+	allExisting: CreatedOctokitRequest[] | undefined,
+	added: CreatedOctokitRequest,
+): CreatedOctokitRequest[] {
+	if (!allExisting) {
+		return [added];
+	}
+
+	for (let i = 0; i < allExisting.length; i++) {
+		const existing = allExisting[i];
+		if (added.endpoint !== existing.endpoint || added.id !== existing.id) {
+			continue;
+		}
+
+		const merged: typeof existing = withoutUndefinedProperties({
+			...existing,
+			parameters: Object.assign({}, existing.parameters, added.parameters),
+		});
+
+		allExisting[i] = merged;
+
+		return allExisting;
+	}
+
+	return [...allExisting, added];
+}
diff --git a/packages/bingo/src/mergers/mergeRequests.ts b/packages/bingo/src/mergers/mergeRequests.ts
index 24318d2..2219fb1 100644
--- a/packages/bingo/src/mergers/mergeRequests.ts
+++ b/packages/bingo/src/mergers/mergeRequests.ts
@@ -1,14 +1,36 @@
-import { CreatedRequest } from "../types/creations.js";
+import {
+	CreatedFetchRequest,
+	CreatedOctokitRequest,
+	CreatedRequest,
+} from "bingo-requests";
+
+import { groupBy } from "../utils/groupBy.js";
+import { mergeFetchRequests } from "./mergeFetchRequests.js";
+import { mergeOctokitRequests } from "./mergeOctokitRequests.js";
 
 export function mergeRequests(
 	firsts: CreatedRequest[],
 	seconds: CreatedRequest[],
 ) {
-	const byId = new Map<string, CreatedRequest>();
+	const byType = groupBy(firsts, (request) => request.type);
+
+	for (const added of seconds) {
+		switch (added.type) {
+			case "fetch":
+				byType.fetch = mergeFetchRequests(
+					byType.fetch as CreatedFetchRequest[],
+					added,
+				);
+				break;
 
-	for (const request of [...firsts, ...seconds]) {
-		byId.set(request.id, request);
+			case "octokit":
+				byType.octokit = mergeOctokitRequests(
+					byType.octokit as CreatedOctokitRequest[],
+					added,
+				);
+				break;
+		}
 	}
 
-	return Array.from(byId.values());
+	return Object.values(byType).flat();
 }
diff --git a/packages/bingo/src/runners/applyRequestsToSystem.ts b/packages/bingo/src/runners/applyRequestsToSystem.ts
index 45aef11..dfbc957 100644
--- a/packages/bingo/src/runners/applyRequestsToSystem.ts
+++ b/packages/bingo/src/runners/applyRequestsToSystem.ts
@@ -1,21 +1,25 @@
-import { CreatedRequest } from "../types/creations.js";
+import { CreatedRequest } from "bingo-requests";
+
 import { SystemContext } from "../types/system.js";
+import { getRequestSender } from "./getRequestSender.js";
 
 export async function applyRequestsToSystem(
 	requests: CreatedRequest[],
-	system: Pick<SystemContext, "display" | "fetchers">,
+	system: SystemContext,
 ) {
 	await Promise.all(
 		requests.map(async (request) => {
-			system.display.item("request", request.id, { start: Date.now() });
+			const { id, send } = getRequestSender(request);
+
+			system.display.item("request", id, { start: Date.now() });
 
 			try {
-				await request.send(system.fetchers);
+				await send(system.fetchers);
 			} catch (error) {
-				system.display.item("request", request.id, { error });
+				system.display.item("request", id, { error });
 			}
 
-			system.display.item("request", request.id, { end: Date.now() });
+			system.display.item("request", id, { end: Date.now() });
 		}),
 	);
 }
diff --git a/packages/bingo/src/runners/getRequestSender.ts b/packages/bingo/src/runners/getRequestSender.ts
new file mode 100644
index 0000000..335667a
--- /dev/null
+++ b/packages/bingo/src/runners/getRequestSender.ts
@@ -0,0 +1,32 @@
+import { CreatedRequest } from "bingo-requests";
+import { SystemFetchers } from "bingo-systems";
+
+export interface RequestSender {
+	id: string;
+	send: (fetchers: SystemFetchers) => Promise<void>;
+}
+
+export function getRequestSender(request: CreatedRequest): RequestSender {
+	switch (request.type) {
+		case "fetch":
+			return {
+				id: request.id ?? request.url,
+				send: async (fetchers: SystemFetchers) => {
+					await fetchers.fetch(request.url, request.init);
+				},
+			};
+
+		case "octokit":
+			return {
+				id: request.id ?? request.endpoint,
+				send: async (fetchers: SystemFetchers) => {
+					await fetchers.octokit.request(
+						request.endpoint,
+						// TODO: I have no idea how to get this to type-check without the any.
+						// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
+						request.parameters as any,
+					);
+				},
+			};
+	}
+}
diff --git a/packages/bingo/src/types/creations.ts b/packages/bingo/src/types/creations.ts
index 3465f4d..e57e973 100644
--- a/packages/bingo/src/types/creations.ts
+++ b/packages/bingo/src/types/creations.ts
@@ -1,16 +1,7 @@
 import { CreatedDirectory } from "bingo-fs";
+import { CreatedRequest } from "bingo-requests";
 import { SystemFetchers } from "bingo-systems";
 
-/**
- * A request to make to set up part of a repository's tooling.
- * @todo This will eventually become a serializable format:
- * https://github.com/JoshuaKGoldberg/bingo/issues/65
- */
-export interface CreatedRequest {
-	id: string;
-	send: CreatedRequestSender;
-}
-
 export type CreatedRequestSender = (fetchers: SystemFetchers) => Promise<void>;
 
 /**
diff --git a/packages/site/astro.config.ts b/packages/site/astro.config.ts
index 3b845fd..93045dd 100644
--- a/packages/site/astro.config.ts
+++ b/packages/site/astro.config.ts
@@ -95,6 +95,10 @@ export default defineConfig({
 								items: [
 									{ label: "bingo", link: "build/packages/bingo" },
 									{ label: "bingo-fs", link: "build/packages/bingo-fs" },
+									{
+										label: "bingo-requests",
+										link: "build/packages/bingo-requests",
+									},
 									{
 										label: "bingo-systems",
 										link: "build/packages/bingo-systems",
diff --git a/packages/site/src/content/docs/build/apis/create-input.mdx b/packages/site/src/content/docs/build/apis/create-input.mdx
index 78572a1..649f0f9 100644
--- a/packages/site/src/content/docs/build/apis/create-input.mdx
+++ b/packages/site/src/content/docs/build/apis/create-input.mdx
@@ -59,7 +59,7 @@ import { createInput } from "bingo";
 
 export const inputFromExampleURL = createInput({
 	async produce({ fetchers }) {
-		return await (await fetchers.fetch("https://example.com")).text();
+		return await (await fetchers.fetch("https://create.bingo")).text();
 	},
 });
 ```
diff --git a/packages/site/src/content/docs/build/concepts/creations.mdx b/packages/site/src/content/docs/build/concepts/creations.mdx
index 0135f0c..8703a5a 100644
--- a/packages/site/src/content/docs/build/concepts/creations.mdx
+++ b/packages/site/src/content/docs/build/concepts/creations.mdx
@@ -66,32 +66,30 @@ See [Packages > `bingo-fs` > Files](/build/packages/bingo-fs#files) for more inf
 Network requests to send after files are created.
 
 This can be useful when repository settings or other APIs are necessary to align the created repository to what templates have produced.
+Each object under `requests` describes a network request to send.
 
-Each request may be specified as an object with:
-
-- `id` (`string`): Unique ID to know how to deduplicate previous creations during [merging](/build/details/merging#requests)
-- `send` (function): Asynchronous method that sends the request, which receives an object parameter containing:
-  - `fetch`: Equivalent to the global `fetch`
-  - `octokit`: [GitHub Octokit](https://github.com/octokit/octokit.js?tab=readme-ov-file#octokit-api-client) that uses the `fetch` internally
-
-For example, this template a GitHub repository's default branch to `main`:
+For example, this template populates a `good first issue` label:
 
 ```ts
 import { createTemplate } from "bingo";
+import { z } from "zod";
 
 export default createTemplate({
+	options: {
+		owner: z.string(),
+		repository: z.string(),
+	},
 	produce({ options }) {
 		return {
 			requests: [
 				{
-					id: "default-branch",
-					async send({ octokit }) {
-						await octokit.rest.repos.update({
-							default_branch: "main",
-							owner: options.owner,
-							repo: options.repository,
-						});
+					endpoint: "POST /repos/{owner}/{repo}/labels",
+					parameters: {
+						owner: options.owner,
+						repo: options.repository,
+						title: "good first issue",
 					},
+					type: "octokit",
 				},
 			],
 		};
@@ -99,6 +97,8 @@ export default createTemplate({
 });
 ```
 
+See [Packages > `bingo-requests` > Requests](/build/packages/bingo-requests#requests) for more information on the `requests` format.
+
 ### `scripts`
 
 Terminal commands to run after files are created.
diff --git a/packages/site/src/content/docs/build/details/merging.mdx b/packages/site/src/content/docs/build/details/merging.mdx
index 91eda67..e7c6130 100644
--- a/packages/site/src/content/docs/build/details/merging.mdx
+++ b/packages/site/src/content/docs/build/details/merging.mdx
@@ -55,56 +55,79 @@ The merged result would be:
 
 ## Requests
 
-[Requests](/build/concepts/creations#requests) are deduplicated based on their `id`.
-Later-created requests will override any previously created requests with the same `id`.
+[Requests](/build/concepts/creations#requests) are merged base on their `id` and `type`:
+_ If two requests have different `id` values, they are always considered separate
+_ Otherwise, requests are merged based on their `type`:
+_ `fetch` requests have `init` merged with `Object.assign()` if they contain the same `url`
+_ `octokit` requests have `parameters` merged with `Object.assign()` if they contain the same `endpoint`
 
-For example, given the following two `requests` Creations to be merged:
+For example, the following two `requests` Creations would not be merged, because they have different endpoints:
 
 ```ts
 [
 	{
-		id: "branch-protection",
-		async send({ octokit }) {
-			console.log("TODO: Set up branch protection");
-		},
+		endpoint: "POST /repos/{owner}/{repo}/autolinks",
+		parameters: {
+			key_prefix: "TICKET-",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+			url_template: "https://example.com/TICKET?query=<num>",
+		}
+		type: "octokit",
 	},
-];
+	{
+		endpoint: "POST /repos/{owner}/{repo}/issues",
+		parameters: {
+			name: "good first issue",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		}
+		type: "octokit",
+	}
+]
 ```
 
+However, the following two `requests` Creations would be merged into one with the same endpoint:
+
 ```ts
 [
 	{
-		id: "branch-protection",
-		async send({ octokit }) {
-			await octokit.request(
-				`PUT /repos/{owner}/{repository}/branches/{branch}/protection`,
-				{
-					branch: "main",
-					// ...
-				},
-			);
-		},
+		endpoint: "POST /repos/{owner}/{repo}/issues",
+		parameters: {
+			color: "#ffcc00",
+			name: "good first issue (pending color)",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		}
+		type: "octokit",
 	},
-];
+	{
+		endpoint: "POST /repos/{owner}/{repo}/issues",
+		parameters: {
+			name: "good first issue",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		}
+		type: "octokit",
+	}
+]
 ```
 
-The merged result would be just the latter Creation:
+The merged result would be:
 
 ```ts
 [
 	{
-		id: "branch-protection",
-		async send({ octokit }) {
-			await octokit.request(
-				`PUT /repos/{owner}/{repository}/branches/{branch}/protection`,
-				{
-					branch: "main",
-					// ...
-				},
-			);
-		},
-	},
-];
+		endpoint: "POST /repos/{owner}/{repo}/issues",
+		parameters: {
+			color: "#ffcc00",
+			name: "good first issue",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		}
+		type: "octokit",
+	}
+]
 ```
 
 ## Scripts
diff --git a/packages/site/src/content/docs/build/packages/bingo-requests.mdx b/packages/site/src/content/docs/build/packages/bingo-requests.mdx
new file mode 100644
index 0000000..e0f70ad
--- /dev/null
+++ b/packages/site/src/content/docs/build/packages/bingo-requests.mdx
@@ -0,0 +1,149 @@
+---
+description: The virtual file system used by Bingo. 🗄️
+title: bingo-requests
+---
+
+import { PackageManagers } from "starlight-package-managers";
+
+Descriptions of network requests as used by Bingo. 📠
+
+<PackageManagers type="add" pkg="bingo-requests" dev />
+
+The separate `bingo-requests` package includes types for the serialized request objects used in [Concepts > Creations > `requests`](/build/concepts/creations#requests).
+
+These requests are type-safe when possible, most notably with GitHub Octokit endpoints.
+
+## Requests
+
+All request objects may contain the following properties:
+
+- `id` _(optional)_: Unique ID for logging and to differentiate between requests to the same endpoint.
+  - See [Merging> `requests`](/build/details/merging#requests) for how requests are merged
+- `type` _(required)_: Which type of request this is, as either:
+  - `"fetch"`: A [`CreatedFetchRequest`](#createdfetchrequest)
+  - `"octokit"`: A [`CreatedOctokitRequest`](#createdoctokitrequest)
+
+### `CreatedFetchRequest`
+
+Description of a request to send with [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API).
+
+These additionally contain:
+
+- `init` _(optional)_: Any [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options to be sent as the second parameter to `fetch()`
+- `url` _(required)_: URL to send the request to
+
+For example, this object describes a `GET` request to `https://create.bingo`:
+
+```ts
+import { CreatedFetchRequest } from "bingo-requests";
+
+export const request: CreatedFetchRequest = {
+	type: "fetch",
+	url: "https://create.bingo",
+};
+```
+
+The `id` field can be useful to disambiguate two requests to the same URL.
+Bingo by default merges fetch requests that have the same `url` data into one request.
+If you need to send two requests to the same URL, you can give them different `id` values.
+
+For example, these two objects describe separate `POST` requests to `https://create.bingo`:
+
+```ts
+import { CreatedFetchRequest } from "bingo-requests";
+
+export const requests: CreatedFetchRequest[] = [
+	{
+		id: "first",
+		init: {
+			body: "(first)",
+			method: "POST",
+		},
+		type: "fetch",
+		url: "https://create.bingo",
+	},
+	{
+		id: "second",
+		init: {
+			body: "(second)",
+			method: "POST",
+		},
+		type: "fetch",
+		url: "https://create.bingo",
+	},
+];
+```
+
+:::tip
+Fetch requests are generally not type-safe.
+If you would like `bingo-requests` to allow type-safe requests to a type of endpoint other than Octokit, please [file a feature request on GitHub](https://github.com/JoshuaKGoldberg/bingo/issues/new?template=03-feature.yml).
+See also: [bingo/77 🚀 Feature: Allow auth and repository hosts other than GitHub](https://github.com/JoshuaKGoldberg/bingo/issues/77).
+:::
+
+### `CreatedOctokitRequest`
+
+Description of a request to send with [Octokit](https://octokit.github.io/rest.js).
+
+These additionally contain:
+
+- `endpoint`: Octokit endpoint to send to
+- `parameters`: Parameter data to attach to the request
+
+For example, this object describes a request to add a label to a repository:
+
+```ts
+import { CreatedOctokitRequest } from "bingo-requests";
+
+export const request: CreatedOctokitRequest = {
+	endpoint: "POST /repos/{owner}/{repo}/labels",
+	parameters: {
+		name: "good first issue",
+		owner: "JoshuaKGoldberg",
+		repo: "create-typescript-app",
+	},
+	type: "octokit",
+};
+```
+
+The `id` field can be useful to disambiguate two requests to the same endpoint.
+Bingo by default merges Octokit requests that have the same `url` data into one request.
+If you need to send two requests to the same endpoint, you can give them different `id` values.
+
+For example, these two objects describe separate requests to add labels to a repository:
+
+```ts
+import { CreatedFetchRequest } from "bingo-requests";
+
+export const requests: CreatedFetchRequest[] = [
+	{
+		endpoint: "POST /repos/{owner}/{repo}/labels",
+		id: "first",
+		parameters: {
+			name: "good first issue",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		},
+		type: "octokit",
+	},
+	{
+		endpoint: "POST /repos/{owner}/{repo}/labels",
+		id: "second",
+		parameters: {
+			name: "wontfix",
+			owner: "JoshuaKGoldberg",
+			repo: "create-typescript-app",
+		},
+		type: "octokit",
+	},
+];
+```
+
+Octokit requests are generally type-safe, in that their types are generated from [`@octokit/types`](https://www.npmjs.com/package/@octokit/types):
+
+- `endpoint` is typed as the keys of [`Endpoints`](https://octokit.github.io/types.ts/interfaces/Endpoints.html)
+- `parameters` is typed as the parameters for the endpoint `&` [`RequestParameters`](https://octokit.github.io/types.ts/types/RequestParameters.html)
+
+:::tip
+If you would like `bingo-requests` to allow type-safe requests to a type of endpoint other than Octokit, such as a different common repository host, please [file a feature request on GitHub](https://github.com/JoshuaKGoldberg/bingo/issues/new?template=03-feature.yml).
+See also: [bingo/77 🚀 Feature: Allow auth and repository hosts other than GitHub](https://github.com/JoshuaKGoldberg/bingo/issues/77).
+:::
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 38c0e24..c93e45a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -110,6 +110,9 @@ importers:
       bingo-fs:
         specifier: workspace:^
         version: link:../bingo-fs
+      bingo-requests:
+        specifier: workspace:^
+        version: link:../bingo-requests
       bingo-systems:
         specifier: workspace:^
         version: link:../bingo-systems
@@ -190,6 +193,12 @@ importers:
         specifier: ^3.24.2
         version: 3.24.2
 
+  packages/bingo-requests:
+    dependencies:
+      '@octokit/types':
+        specifier: ^13.8.0
+        version: 13.8.0
+
   packages/bingo-stratum:
     dependencies:
       bingo-fs:
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 6bd14f7..af047ed 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -7,6 +7,7 @@
 	"files": [],
 	"references": [
 		{ "path": "./packages/bingo-fs" },
+		{ "path": "./packages/bingo-requests" },
 		{ "path": "./packages/bingo-systems" },
 		{ "path": "./packages/bingo" },
 		{ "path": "./packages/bingo-testers" },


Test patch:
diff --git a/packages/bingo-requests/vitest.config.ts b/packages/bingo-requests/vitest.config.ts
new file mode 100644
index 0000000..5966133
--- /dev/null
+++ b/packages/bingo-requests/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineProject } from "vitest/config";
+
+export default defineProject({
+	test: {
+		clearMocks: true,
+		exclude: ["lib", "node_modules"],
+	},
+});
diff --git a/packages/bingo/src/mergers/mergeFetchRequests.test.ts b/packages/bingo/src/mergers/mergeFetchRequests.test.ts
new file mode 100644
index 0000000..032f026
--- /dev/null
+++ b/packages/bingo/src/mergers/mergeFetchRequests.test.ts
@@ -0,0 +1,74 @@
+import { CreatedFetchRequest } from "bingo-requests";
+import { describe, expect, it } from "vitest";
+
+import { mergeFetchRequests } from "./mergeFetchRequests.js";
+
+describe("mergeFetchRequests", () => {
+	it("returns the second request when there are no existing requests", () => {
+		const second: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const actual = mergeFetchRequests([], second);
+
+		expect(actual).toEqual([second]);
+	});
+
+	it("returns both requests when they have different URLs", () => {
+		const first: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo/about",
+		};
+
+		const second: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo/build/about",
+		};
+
+		const actual = mergeFetchRequests([first], second);
+
+		expect(actual).toEqual([first, second]);
+	});
+
+	it("returns just the first request when a next request is identical", () => {
+		const first: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const second = { ...first };
+
+		const actual = mergeFetchRequests([first], second);
+
+		expect(actual).toEqual([first]);
+	});
+
+	it("returns a merged init version of the first request when a next request has the same endpoint and different init", () => {
+		const first: CreatedFetchRequest = {
+			init: { keepalive: false, method: "GET" },
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const second: CreatedFetchRequest = {
+			init: { body: "...", method: "POST" },
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const actual = mergeFetchRequests([first], second);
+
+		expect(actual).toEqual([
+			{
+				init: {
+					body: "...",
+					keepalive: false,
+					method: "POST",
+				},
+				type: "fetch",
+				url: "https://create.bingo",
+			},
+		]);
+	});
+});
diff --git a/packages/bingo/src/mergers/mergeOctokitRequests.test.ts b/packages/bingo/src/mergers/mergeOctokitRequests.test.ts
new file mode 100644
index 0000000..e048e05
--- /dev/null
+++ b/packages/bingo/src/mergers/mergeOctokitRequests.test.ts
@@ -0,0 +1,88 @@
+import { CreatedOctokitRequest } from "bingo-requests";
+import { describe, expect, it } from "vitest";
+
+import { mergeOctokitRequests } from "./mergeOctokitRequests.js";
+
+const owner = "test-owner";
+const repo = "test-repo";
+const title = "Test Title";
+
+describe("mergeOctokitRequests", () => {
+	it("returns the second request when there are no existing requests", () => {
+		const second: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
+		};
+
+		const actual = mergeOctokitRequests([], second);
+
+		expect(actual).toEqual([second]);
+	});
+
+	it("returns both requests when they have different endpoints", () => {
+		const first: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
+		};
+
+		const second: CreatedOctokitRequest<"POST /repos/{owner}/{repo}/issues"> = {
+			endpoint: "POST /repos/{owner}/{repo}/issues",
+			parameters: { owner, repo, title },
+			type: "octokit",
+		};
+
+		const actual = mergeOctokitRequests([first], second);
+
+		expect(actual).toEqual([first, second]);
+	});
+
+	it("returns just the first request when a next request is identical", () => {
+		const first: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "first", owner, repo },
+			type: "octokit",
+		};
+
+		const second = { ...first };
+
+		const actual = mergeOctokitRequests([first], second);
+
+		expect(actual).toEqual([first]);
+	});
+
+	it("returns a merged parameters version of the first request when a next request has the same endpoint and different data", () => {
+		const first: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: {
+				description: "First description.",
+				name: "first",
+				owner,
+				repo,
+			},
+			type: "octokit",
+		};
+
+		const second: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "second", owner, repo },
+			type: "octokit",
+		};
+
+		const actual = mergeOctokitRequests([first], second);
+
+		expect(actual).toEqual([
+			{
+				endpoint: "POST /repos/{owner}/{repo}/labels",
+				parameters: {
+					description: "First description.",
+					name: "second",
+					owner,
+					repo,
+				},
+				type: "octokit",
+			},
+		]);
+	});
+});
diff --git a/packages/bingo/src/mergers/mergeRequests.test.ts b/packages/bingo/src/mergers/mergeRequests.test.ts
index 8885e00..d4e7822 100644
--- a/packages/bingo/src/mergers/mergeRequests.test.ts
+++ b/packages/bingo/src/mergers/mergeRequests.test.ts
@@ -1,16 +1,22 @@
-import { describe, expect, it, vi } from "vitest";
+import { CreatedFetchRequest, CreatedOctokitRequest } from "bingo-requests";
+import { describe, expect, it } from "vitest";
 
 import { mergeRequests } from "./mergeRequests.js";
 
+const owner = "test-owner";
+const repo = "test-repo";
+
 describe("mergeRequests", () => {
-	it("returns both requests when they have different ids", () => {
-		const first = {
-			id: "a",
-			send: vi.fn(),
+	it("returns fetch and octokit requests together when both are provided", () => {
+		const first: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
 		};
-		const second = {
-			id: "b",
-			send: vi.fn(),
+
+		const second: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo",
 		};
 
 		const actual = mergeRequests([first], [second]);
@@ -18,18 +24,23 @@ describe("mergeRequests", () => {
 		expect(actual).toEqual([first, second]);
 	});
 
-	it("returns only the second request when they have the same id", () => {
-		const first = {
-			id: "a",
-			send: vi.fn(),
+	it("returns one merged request when a new request can be merged", () => {
+		const first: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
 		};
-		const second = {
-			id: "a",
-			send: vi.fn(),
+
+		const second: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo",
 		};
 
-		const actual = mergeRequests([first], [second]);
+		const actual = mergeRequests(
+			[first, second],
+			[{ ...second, init: { method: "POST" } }],
+		);
 
-		expect(actual).toEqual([second]);
+		expect(actual).toEqual([{ ...second, init: { method: "POST" } }, first]);
 	});
 });
diff --git a/packages/bingo/src/runners/applyRequestsToSystem.test.ts b/packages/bingo/src/runners/applyRequestsToSystem.test.ts
index a3ef73a..d114dc5 100644
--- a/packages/bingo/src/runners/applyRequestsToSystem.test.ts
+++ b/packages/bingo/src/runners/applyRequestsToSystem.test.ts
@@ -1,10 +1,20 @@
+import { WritingFileSystem } from "bingo-systems";
 import { Octokit } from "octokit";
 import { describe, expect, it, vi } from "vitest";
 
 import { applyRequestsToSystem } from "./applyRequestsToSystem.js";
 
+const mockGetRequestSender = vi.fn();
+
+vi.mock("./getRequestSender.js", () => ({
+	get getRequestSender() {
+		return mockGetRequestSender;
+	},
+}));
+
 function createStubSystem() {
 	return {
+		directory: "",
 		display: {
 			item: vi.fn(),
 			log: vi.fn(),
@@ -13,20 +23,48 @@ function createStubSystem() {
 			fetch: vi.fn(),
 			octokit: {} as Octokit,
 		},
+		fs: {} as WritingFileSystem,
+		runner: vi.fn(),
 	};
 }
 
 describe("applyRequestsToSystem", () => {
+	it("does not display an error when a request  does not reject", async () => {
+		const id = "abc";
+		const send = vi.fn();
+		const system = createStubSystem();
+
+		mockGetRequestSender.mockReturnValueOnce({ id, send });
+
+		await applyRequestsToSystem(
+			[
+				{
+					type: "fetch",
+					url: "https://example.com",
+				},
+			],
+			system,
+		);
+
+		expect(system.display.item.mock.calls).toEqual([
+			["request", id, { start: expect.any(Number) }],
+			["request", id, { end: expect.any(Number) }],
+		]);
+	});
+
 	it("displays the error when a request rejects", async () => {
 		const error = new Error();
 		const id = "abc";
+		const send = vi.fn().mockRejectedValueOnce(error);
 		const system = createStubSystem();
 
+		mockGetRequestSender.mockReturnValueOnce({ id, send });
+
 		await applyRequestsToSystem(
 			[
 				{
-					id,
-					send: vi.fn().mockRejectedValueOnce(error),
+					type: "fetch",
+					url: "https://example.com",
 				},
 			],
 			system,
diff --git a/packages/bingo/src/runners/getRequestSender.test.ts b/packages/bingo/src/runners/getRequestSender.test.ts
new file mode 100644
index 0000000..455ac91
--- /dev/null
+++ b/packages/bingo/src/runners/getRequestSender.test.ts
@@ -0,0 +1,110 @@
+import { CreatedFetchRequest, CreatedOctokitRequest } from "bingo-requests";
+import { SystemFetchers } from "bingo-systems";
+import { describe, expect, it, vi } from "vitest";
+
+import { getRequestSender } from "./getRequestSender.js";
+
+function createMockSystemFetchers() {
+	return {
+		fetch: vi.fn(),
+		octokit: { request: vi.fn() },
+	};
+}
+
+const owner = "test-owner";
+const repo = "test-repo";
+
+describe("getRequestSender", () => {
+	it("returns a sender with url for id when given a fetch request without id", () => {
+		const request: CreatedFetchRequest = {
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const actual = getRequestSender(request);
+
+		expect(actual).toEqual({
+			id: request.url,
+			send: expect.any(Function),
+		});
+	});
+
+	it("returns a sender with id for id when given a fetch request with id", () => {
+		const request: CreatedFetchRequest = {
+			id: "abc123",
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		const actual = getRequestSender(request);
+
+		expect(actual).toEqual({
+			id: request.id,
+			send: expect.any(Function),
+		});
+	});
+
+	it("sends a request with fetchers.request sending a fetch request", async () => {
+		const fetchers = createMockSystemFetchers();
+
+		const request: CreatedFetchRequest = {
+			id: "abc123",
+			type: "fetch",
+			url: "https://create.bingo",
+		};
+
+		await getRequestSender(request).send(fetchers as unknown as SystemFetchers);
+
+		expect(fetchers.fetch).toHaveBeenCalledWith(request.url, request.init);
+		expect(fetchers.octokit.request).not.toHaveBeenCalled();
+	});
+
+	it("returns a sender with url for id when given an Octokit request without id", () => {
+		const request: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
+		};
+
+		const actual = getRequestSender(request);
+
+		expect(actual).toEqual({
+			id: request.endpoint,
+			send: expect.any(Function),
+		});
+	});
+
+	it("returns a sender with id for id when given an Octokit request with id", () => {
+		const request: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			id: "abc123",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
+		};
+
+		const actual = getRequestSender(request);
+
+		expect(actual).toEqual({
+			id: request.id,
+			send: expect.any(Function),
+		});
+	});
+
+	it("sends a request with fetchers.octokit.request sending an Octokit request", async () => {
+		const fetchers = createMockSystemFetchers();
+
+		const request: CreatedOctokitRequest = {
+			endpoint: "POST /repos/{owner}/{repo}/labels",
+			parameters: { name: "...", owner, repo },
+			type: "octokit",
+		};
+
+		await getRequestSender(request).send(fetchers as unknown as SystemFetchers);
+
+		expect(fetchers.fetch).not.toHaveBeenCalled();
+		expect(fetchers.octokit.request).toHaveBeenCalledWith(
+			request.endpoint,
+			request.parameters,
+		);
+	});
+});
diff --git a/packages/site/src/content/docs/build/packages/bingo-testers.mdx b/packages/site/src/content/docs/build/packages/bingo-testers.mdx
index 13c247f..836e3c6 100644
--- a/packages/site/src/content/docs/build/packages/bingo-testers.mdx
+++ b/packages/site/src/content/docs/build/packages/bingo-testers.mdx
@@ -215,7 +215,7 @@ import { inputGitUserEmail } from "./inputGitUserEmail";
 
 describe("inputGitUserEmail", () => {
 	it("returns text from git config user.email", async () => {
-		const email = "rick.astley@example.com";
+		const email = "rick.astley@create.bingo";
 
 		const runner = vi.fn().mockResolvedValueOnce({
 			stdout: email,