Repository: JoshuaKGoldberg/template-typescript-node-package (ts)

Issue:
🚀 Feature: Hydration should try npm and git config for email
### 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

Right now, when running the _hydration_ script, if an email address isn't provided with `--email`, a fallback from the existing `package.json` `"author"` is tried:

https://github.com/JoshuaKGoldberg/template-typescript-node-package/blob/6a0d42d7f07e3a70c7b0dd239c7431c58f43bd69/src/hydrate/values/getHydrationDefaults.ts#L29-L32

However, not all packages include an email in their author. Some just provide it as a string like `"author": "Josh Goldberg"`. 

In this case, it'd be nice to try, in order:
1. The user's npm account (re-using logic from `getNpmAuthor.ts`
2. The user's Git config (`git config --get user.email`)

### Additional Info

Hydration is explained here: https://github.com/JoshuaKGoldberg/template-typescript-node-package/tree/6a0d42d7f07e3a70c7b0dd239c7431c58f43bd69#repository-hydration

Pull request description:
feat: retrieve owner email from npm or git config
<!-- 👋 Hi, thanks for sending a PR to template-typescript-node-package! 💖.
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 #499
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/template-typescript-node-package/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/template-typescript-node-package/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

<!-- Description of what is changed and how the code change does that. -->
- Extracted the logic of retrieving the npm user info to a new function and modified the tests accordingly
- `getHydrationValues` will try to retrieve the email from the npm user info, and in case of failure, will reach out to git config.

* Note: I was not sure how to properly test the new functionality rather than modifying the tests, please advice, or check the new functionality to provide me with feedback.

Patch:
diff --git a/src/hydrate/values/getHydrationDefaults.ts b/src/hydrate/values/getHydrationDefaults.ts
index 1268d28..1bffd86 100644
--- a/src/hydrate/values/getHydrationDefaults.ts
+++ b/src/hydrate/values/getHydrationDefaults.ts
@@ -1,17 +1,12 @@
 import { $ } from "execa";
 
+import type { PartialPackageData } from "./types.js";
+
 import { getNpmAuthor } from "../../shared/getNpmAuthor.js";
 import { readFileSafe } from "../readFileSafe.js";
+import { readEmailIfExists } from "./readEmailIfExists.js";
 import { readFundingIfExists } from "./readFundingIfExists.js";
 
-interface PartialPackageData {
-	author?: { email: string; name: string } | string;
-	description?: string;
-	email?: string;
-	name?: string;
-	repository?: string;
-}
-
 export async function getHydrationDefaults() {
 	const existingReadme = await readFileSafe("./README.md", "");
 	const existingPackage = JSON.parse(
@@ -27,10 +22,7 @@ export async function getHydrationDefaults() {
 
 			return fromPackage ?? (await getNpmAuthor());
 		},
-		email:
-			typeof existingPackage.author === "string"
-				? existingPackage.author.split(/<|>/)[1]
-				: existingPackage.author?.email,
+		email: () => readEmailIfExists(existingPackage),
 		funding: readFundingIfExists,
 		owner: async () =>
 			(await $`git remote -v`).stdout.match(
diff --git a/src/hydrate/values/readEmailIfExists.ts b/src/hydrate/values/readEmailIfExists.ts
new file mode 100644
index 0000000..bc1a74a
--- /dev/null
+++ b/src/hydrate/values/readEmailIfExists.ts
@@ -0,0 +1,29 @@
+import { $ } from "execa";
+
+import type { PartialPackageData } from "./types.js";
+
+import { getNpmUserInfo } from "../../shared/getNpmUserInfo.js";
+
+export async function readEmailIfExists(
+	existingPackage: PartialPackageData
+): Promise<string | undefined> {
+	const fromPackage =
+		typeof existingPackage.author === "string"
+			? existingPackage.author.split(/<|>/)[1]
+			: existingPackage.author?.email;
+	if (fromPackage) {
+		return fromPackage;
+	}
+
+	const result = await getNpmUserInfo();
+	if (result.succeeded) {
+		return result.value.email;
+	}
+
+	try {
+		const { stdout } = await $`git config --get user.email`;
+		return stdout;
+	} catch {
+		return undefined;
+	}
+}
diff --git a/src/hydrate/values/types.ts b/src/hydrate/values/types.ts
index 11426ce..6a9b713 100644
--- a/src/hydrate/values/types.ts
+++ b/src/hydrate/values/types.ts
@@ -4,3 +4,11 @@ export interface HydrationInputValues extends InputValues {
 	author: string;
 	email: string;
 }
+
+export interface PartialPackageData {
+	author?: { email: string; name: string } | string;
+	description?: string;
+	email?: string;
+	name?: string;
+	repository?: string;
+}
diff --git a/src/shared/getNpmAuthor.ts b/src/shared/getNpmAuthor.ts
index ab64a9a..4998384 100644
--- a/src/shared/getNpmAuthor.ts
+++ b/src/shared/getNpmAuthor.ts
@@ -1,45 +1,21 @@
 import chalk from "chalk";
-import { $ } from "execa";
-import npmUser from "npm-user";
 
 import { logLine } from "./cli/lines.js";
+import { getNpmUserInfo } from "./getNpmUserInfo.js";
 
 export async function getNpmAuthor(): Promise<string | undefined>;
 export async function getNpmAuthor(owner: string): Promise<string>;
 export async function getNpmAuthor(
 	owner?: string | undefined
 ): Promise<string | undefined> {
-	let username;
-
-	try {
-		const { stdout } = await $`npm whoami`;
-
-		username = stdout;
-	} catch {
+	const result = await getNpmUserInfo();
+	if (!result.succeeded) {
 		logLine();
-		logLine(
-			chalk.gray("Could not populate npm user. Failed to run npm whoami.")
-		);
-
-		return owner;
-	}
-
-	let npmUserInfo;
-
-	try {
-		npmUserInfo = await npmUser(username);
-	} catch {
-		logLine();
-		logLine(
-			chalk.gray(
-				"Could not populate npm user. Failed to retrieve user info from npm."
-			)
-		);
-
+		logLine(chalk.gray(result.reason));
 		return owner;
 	}
 
-	const { email, name = owner } = npmUserInfo;
+	const { email, name = owner } = result.value;
 	// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 	return email ? `${name!} <${email}>` : name;
 }
diff --git a/src/shared/getNpmUserInfo.ts b/src/shared/getNpmUserInfo.ts
new file mode 100644
index 0000000..cbd0b1b
--- /dev/null
+++ b/src/shared/getNpmUserInfo.ts
@@ -0,0 +1,38 @@
+import { $ } from "execa";
+import npmUser, { type UserInfo } from "npm-user";
+
+export type { UserInfo };
+interface ErrorResult {
+	reason: string;
+	succeeded: false;
+}
+interface SuccessResult {
+	succeeded: true;
+	value: UserInfo;
+}
+type Result = ErrorResult | SuccessResult;
+
+export async function getNpmUserInfo(): Promise<Result> {
+	let username;
+
+	try {
+		const { stdout } = await $`npm whoami`;
+		username = stdout;
+	} catch {
+		return {
+			reason: "Could not populate npm user. Failed to run npm whoami.",
+			succeeded: false,
+		};
+	}
+
+	try {
+		const user = await npmUser(username);
+		return { succeeded: true, value: user };
+	} catch {
+		return {
+			reason:
+				"Could not populate npm user. Failed to retrieve user info from npm.",
+			succeeded: false,
+		};
+	}
+}


Test patch:
diff --git a/src/hydrate/values/readEmailIfExists.test.ts b/src/hydrate/values/readEmailIfExists.test.ts
new file mode 100644
index 0000000..05ebbd3
--- /dev/null
+++ b/src/hydrate/values/readEmailIfExists.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { readEmailIfExists } from "./readEmailIfExists.js";
+
+const validAuthorPackage = {
+	author: "Author<author@package.com>",
+	email: "author@package.com",
+};
+
+const validEmailPackage = {
+	author: { email: "info@package.com", name: "Author" },
+};
+
+const mock$ = vi.fn();
+
+vi.mock("execa", () => ({
+	get $() {
+		return mock$;
+	},
+}));
+
+const mockGetNpmUserInfo = vi.fn();
+
+vi.mock("../../shared/getNpmUserInfo.js", () => ({
+	get getNpmUserInfo() {
+		return mockGetNpmUserInfo;
+	},
+}));
+
+describe("readEmailIfExists", () => {
+	it('reads email from the package "author" field when it exists', async () => {
+		const email = await readEmailIfExists(validAuthorPackage);
+
+		expect(email).toBe("author@package.com");
+	});
+
+	it("reads email from the email field in an author object when it exists", async () => {
+		const email = await readEmailIfExists(validEmailPackage);
+
+		expect(email).toBe("info@package.com");
+	});
+
+	it("reads email from getNpmUserInfo when available and no author information exists", async () => {
+		mockGetNpmUserInfo.mockResolvedValue({
+			succeeded: true,
+			value: {
+				avatar: "",
+				email: "info@npm.worked",
+				github: "",
+				name: "someone",
+				twitter: "",
+			},
+		});
+
+		const email = await readEmailIfExists({});
+		expect(email).toBe("info@npm.worked");
+	});
+
+	it("reads email from git config when it exists and nothing else worked", async () => {
+		mockGetNpmUserInfo.mockResolvedValue({
+			reason: "it doesn't matter",
+			succeeded: false,
+		});
+		mock$.mockResolvedValue({ stdout: "info@git.worked" });
+		const email = await readEmailIfExists({});
+
+		expect(email).toBe("info@git.worked");
+	});
+
+	it("return undefined when nothing worked", async () => {
+		mockGetNpmUserInfo.mockResolvedValue({
+			reason: "it doesn't matter",
+			succeeded: false,
+		});
+		mock$.mockRejectedValue("");
+		const email = await readEmailIfExists({});
+		expect(email).toBe(undefined);
+	});
+});
diff --git a/src/shared/getNpmAuthor.test.ts b/src/shared/getNpmAuthor.test.ts
index fe8d0f7..a4b1016 100644
--- a/src/shared/getNpmAuthor.test.ts
+++ b/src/shared/getNpmAuthor.test.ts
@@ -3,25 +3,16 @@ import { SpyInstance, beforeEach, describe, expect, it, vi } from "vitest";
 
 import { getNpmAuthor } from "./getNpmAuthor.js";
 
-const mock$ = vi.fn();
+const mockNpmUserInfo = vi.fn();
 
-vi.mock("execa", () => ({
-	get $() {
-		return mock$;
-	},
-}));
-
-const mockNpmUser = vi.fn();
-
-vi.mock("npm-user", () => ({
-	get default() {
-		return mockNpmUser;
+vi.mock("./getNpmUserInfo", () => ({
+	get getNpmUserInfo() {
+		return mockNpmUserInfo;
 	},
 }));
 
 let mockConsoleLog: SpyInstance;
 
-const npmUsername = "test-npm-username";
 const owner = "test-owner";
 
 describe("getNpmAuthor", () => {
@@ -31,48 +22,42 @@ describe("getNpmAuthor", () => {
 			.mockImplementation(() => undefined);
 	});
 
-	it("logs and defaults to owner when npm whoami fails", async () => {
-		mock$.mockRejectedValue({ stderr: "Oh no!" });
+	it("logs and defaults to owner when getNpmUserInfo fails", async () => {
+		mockNpmUserInfo.mockResolvedValue({
+			reason: "Some reason",
+			succeeded: false,
+		});
 
 		const author = await getNpmAuthor(owner);
 
 		expect(author).toBe(owner);
-		expect(mockConsoleLog).toHaveBeenCalledWith(chalk.gray("│"));
 		expect(mockConsoleLog).toHaveBeenCalledWith(
-			[
-				chalk.gray("│"),
-				chalk.gray("Could not populate npm user. Failed to run npm whoami."),
-			].join("  ")
+			[chalk.gray("│"), chalk.gray("Some reason")].join("  ")
 		);
 	});
 
-	it("logs and defaults to owner when retrieving the npm whoami user fails", async () => {
-		mock$.mockResolvedValue({ stdout: npmUsername });
-		mockNpmUser.mockRejectedValue("Oh no!");
+	it("returns npm user info with only name when no email available for npm user", async () => {
+		const name = "Test Author";
+		mockNpmUserInfo.mockResolvedValue({
+			succeeded: true,
+			value: { name },
+		});
 
 		const author = await getNpmAuthor(owner);
-
-		expect(author).toBe(owner);
-		expect(mockConsoleLog).toHaveBeenCalledWith(chalk.gray("│"));
-		expect(mockConsoleLog).toHaveBeenCalledWith(
-			[
-				chalk.gray("│"),
-				chalk.gray(
-					"Could not populate npm user. Failed to retrieve user info from npm."
-				),
-			].join("  ")
-		);
+		expect(author).toBe(name);
+		expect(mockConsoleLog).not.toHaveBeenCalled();
 	});
 
-	it("returns npm user info when retrieving the npm whoami user succeeds", async () => {
-		const name = "Test Author <test@test.test>";
-
-		mock$.mockResolvedValue({ stdout: npmUsername });
-		mockNpmUser.mockResolvedValue({ name });
+	it("returns npm user info when getNpmUserInfo succeeds and contains all information", async () => {
+		const name = "Test Author";
+		const email = "<test@test.test>";
+		mockNpmUserInfo.mockResolvedValue({
+			succeeded: true,
+			value: { email, name },
+		});
 
 		const author = await getNpmAuthor(owner);
-
-		expect(author).toBe(name);
+		expect(author).toBe(`${name} <${email}>`);
 		expect(mockConsoleLog).not.toHaveBeenCalled();
 	});
 });
diff --git a/src/shared/getNpmUserInfo.test.ts b/src/shared/getNpmUserInfo.test.ts
new file mode 100644
index 0000000..42c3587
--- /dev/null
+++ b/src/shared/getNpmUserInfo.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { getNpmUserInfo } from "./getNpmUserInfo.js";
+
+const mock$ = vi.fn();
+
+vi.mock("execa", () => ({
+	get $() {
+		return mock$;
+	},
+}));
+
+const mockNpmUser = vi.fn();
+
+vi.mock("npm-user", () => ({
+	get default() {
+		return mockNpmUser;
+	},
+}));
+
+const npmUsername = "test-npm-username";
+
+describe("getNpmUserInfo", () => {
+	it("returns an error result when npm whoami fails", async () => {
+		mock$.mockRejectedValue({ stderr: "Oh no!" });
+		const result = await getNpmUserInfo();
+
+		expect(result).toEqual({
+			reason: "Could not populate npm user. Failed to run npm whoami.",
+			succeeded: false,
+		});
+	});
+
+	it("returns a success result when the npm whoami user succeeds", async () => {
+		mock$.mockResolvedValue({ stdout: npmUsername });
+		mockNpmUser.mockResolvedValue({ name: npmUsername });
+		const result = await getNpmUserInfo();
+
+		expect(result).toEqual({
+			succeeded: true,
+			value: { name: npmUsername },
+		});
+	});
+
+	it("returns an error result the npm whoami user fails", async () => {
+		mock$.mockResolvedValue({ stdout: npmUsername });
+		mockNpmUser.mockRejectedValue("error");
+		const result = await getNpmUserInfo();
+
+		expect(result).toEqual({
+			reason:
+				"Could not populate npm user. Failed to retrieve user info from npm.",
+			succeeded: false,
+		});
+	});
+});