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

Issue:
🚀 Feature: Infer `blockCodecov` `env` from files on disk
### Feature Request Checklist

- [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.

### Overview

As of https://github.com/JoshuaKGoldberg/bingo/issues/327 -> https://github.com/JoshuaKGoldberg/bingo/pull/328, Blocks can define an `intake` to infer Addons from existing files on disk. Filing this issue to track using this feature in `blockCodecov` to infer `env`.

### Additional Info

This is one of the most common customizations I've made in `create-typescript-app.config.js` files. Enabling it to be inferred will reduce the number of files that have to be explicitly added to repos. 🔪 

Example: https://github.com/JoshuaKGoldberg/get-github-auth-token/pull/375/files#diff-0a58614224d2fb069bf0ef946b864a49635ec6736bf313bf6bf619bf59e6f387R18-R22

🎁 

Pull request:
feat: add intake method to blockCodecov
## PR Checklist

- [x] Addresses an existing open issue: fixes #2076
- [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

Attempts to load an existing `ci.yml` as JSON and find the Codecov step. If that step exists and has an `env`, hooray!

🎁 

Patch:
diff --git a/package.json b/package.json
index 8f46100..eb7debd 100644
--- a/package.json
+++ b/package.json
@@ -37,9 +37,9 @@
 		"*": "prettier --ignore-unknown --write"
 	},
 	"dependencies": {
-		"bingo": "^0.5.11",
-		"bingo-fs": "^0.5.4",
-		"bingo-stratum": "^0.5.8",
+		"bingo": "^0.5.12",
+		"bingo-fs": "^0.5.5",
+		"bingo-stratum": "^0.5.9",
 		"cached-factory": "^0.1.0",
 		"cspell-populate-words": "^0.3.0",
 		"execa": "^9.5.2",
@@ -86,7 +86,7 @@
 		"@vitest/eslint-plugin": "1.1.38",
 		"all-contributors-cli": "6.26.1",
 		"bingo-requests": "0.5.5",
-		"bingo-stratum-testers": "0.5.7",
+		"bingo-stratum-testers": "0.5.8",
 		"bingo-testers": "0.5.7",
 		"console-fail-test": "0.5.0",
 		"cspell": "8.17.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30a76b5..c7fd636 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,14 +9,14 @@ importers:
   .:
     dependencies:
       bingo:
-        specifier: ^0.5.11
-        version: 0.5.11
+        specifier: ^0.5.12
+        version: 0.5.12
       bingo-fs:
-        specifier: ^0.5.4
-        version: 0.5.4
+        specifier: ^0.5.5
+        version: 0.5.5
       bingo-stratum:
-        specifier: ^0.5.8
-        version: 0.5.8(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)(zod@3.24.2)
+        specifier: ^0.5.9
+        version: 0.5.9(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)(zod@3.24.2)
       cached-factory:
         specifier: ^0.1.0
         version: 0.1.0
@@ -40,13 +40,13 @@ importers:
         version: 1.2.0
       input-from-file:
         specifier: ^0.5.4
-        version: 0.5.4(bingo@0.5.11)
+        version: 0.5.4(bingo@0.5.12)
       input-from-file-json:
         specifier: ^0.5.4
-        version: 0.5.4(bingo@0.5.11)(zod@3.24.2)
+        version: 0.5.4(bingo@0.5.12)(zod@3.24.2)
       input-from-script:
         specifier: ^0.5.4
-        version: 0.5.4(bingo@0.5.11)
+        version: 0.5.4(bingo@0.5.12)
       js-yaml:
         specifier: ^4.1.0
         version: 4.1.0
@@ -151,11 +151,11 @@ importers:
         specifier: 0.5.5
         version: 0.5.5
       bingo-stratum-testers:
-        specifier: 0.5.7
-        version: 0.5.7(bingo-fs@0.5.4)(bingo-stratum@0.5.8(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)(zod@3.24.2))(bingo-systems@0.5.4)(bingo-testers@0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11))(bingo@0.5.11)
+        specifier: 0.5.8
+        version: 0.5.8(bingo-fs@0.5.5)(bingo-stratum@0.5.9(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)(zod@3.24.2))(bingo-systems@0.5.4)(bingo-testers@0.5.7(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12))(bingo@0.5.12)
       bingo-testers:
         specifier: 0.5.7
-        version: 0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)
+        version: 0.5.7(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)
       console-fail-test:
         specifier: 0.5.0
         version: 0.5.0
@@ -1641,29 +1641,29 @@ packages:
   before-after-hook@3.0.2:
     resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==}
 
-  bingo-fs@0.5.4:
-    resolution: {integrity: sha512-0dP52RdNAff6F8MoHszk7YAZKZWes4+s9UYANNZVfr87g2KbgY56o6szVXEXytRtay6UuNIkjMHcRTGWw0ODzw==}
+  bingo-fs@0.5.5:
+    resolution: {integrity: sha512-T06BMIQPuJEuDKEQRl206hRFVthMu5byyFf1ZslCRUVRdFExrZ7A5dBU5FZdeT+41nJX89/rvu8wFfNB/LY98Q==}
     engines: {node: '>=18'}
 
   bingo-requests@0.5.5:
     resolution: {integrity: sha512-NQcnlAtnR72Cfn6Y9EWPS3RXAGM+4ndE3BEueLnWOQgj+/Y6kQyhGEiB7cPmi4VeJL+Kcqo/EGyzuF23aIT3Cg==}
     engines: {node: '>=18'}
 
-  bingo-stratum-testers@0.5.7:
-    resolution: {integrity: sha512-ei6K13xK+Dq5WPr/9zgFfFQ+k0eswuJ3IPw8hHN2UgmwZNaMmOAtJi3lWp2nvBYzT+Zra6VtwkZ52uffhLYHmA==}
+  bingo-stratum-testers@0.5.8:
+    resolution: {integrity: sha512-QrAvq03mKTDG0jJtJ6AQ7cKVxA/iFdz6co1SHsu2NkYp/XKnFwchX0jhFeb8VWWoN68cJxv+Abjp6Utmb0qlYA==}
     engines: {node: '>=18'}
     peerDependencies:
-      bingo: ^0.5.10
+      bingo: ^0.5.11
       bingo-fs: ^0.5.4
-      bingo-stratum: ^0.5.7
+      bingo-stratum: ^0.5.8
       bingo-systems: ^0.5.4
       bingo-testers: ^0.5.7
 
-  bingo-stratum@0.5.8:
-    resolution: {integrity: sha512-tjloRkPbNk/P+snaUqs1KfhZOnT7OE/s+KNEFECPuB/AAtgfQnM7YhAk9ZQwVKZtuhEz9n1s0HAxN9wZbJ6n3g==}
+  bingo-stratum@0.5.9:
+    resolution: {integrity: sha512-xajT9gPLGQsgYrvYXi/s0kBwJ2EHkmdxHRvzw0Yd2Wi//EmpMa6EYK9mLJOpp8bOyx9ReUj1Xe/sOMuAeB+44w==}
     engines: {node: '>=18'}
     peerDependencies:
-      bingo: ^0.5.10
+      bingo: ^0.5.11
       bingo-fs: ^0.5.4
       bingo-systems: ^0.5.4
       zod: ^3.24.2
@@ -1680,8 +1680,8 @@ packages:
       bingo-fs: ^0.5.4
       bingo-systems: ^0.5.4
 
-  bingo@0.5.11:
-    resolution: {integrity: sha512-OoInzX/MdhC+6hD5Eps3al//v7ZAVKHGTBcFliqtWrWhDvOndBMZdjZIqyUoh7tAEOH3WemeyfY6fi/6dgnRJg==}
+  bingo@0.5.12:
+    resolution: {integrity: sha512-FPnWa8RnEyoJRZeUeeDRZqZ7P/O+UENucQOYdSK2HfRzZA1gsTTLC8xemUK3+gkGXTOi6pagPAgYaKDiJBZLJA==}
     engines: {node: '>=18'}
     hasBin: true
 
@@ -5754,25 +5754,25 @@ snapshots:
 
   before-after-hook@3.0.2: {}
 
-  bingo-fs@0.5.4: {}
+  bingo-fs@0.5.5: {}
 
   bingo-requests@0.5.5:
     dependencies:
       '@octokit/types': 13.10.0
 
-  bingo-stratum-testers@0.5.7(bingo-fs@0.5.4)(bingo-stratum@0.5.8(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)(zod@3.24.2))(bingo-systems@0.5.4)(bingo-testers@0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11))(bingo@0.5.11):
+  bingo-stratum-testers@0.5.8(bingo-fs@0.5.5)(bingo-stratum@0.5.9(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)(zod@3.24.2))(bingo-systems@0.5.4)(bingo-testers@0.5.7(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12))(bingo@0.5.12):
     dependencies:
-      bingo: 0.5.11
-      bingo-fs: 0.5.4
-      bingo-stratum: 0.5.8(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)(zod@3.24.2)
+      bingo: 0.5.12
+      bingo-fs: 0.5.5
+      bingo-stratum: 0.5.9(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)(zod@3.24.2)
       bingo-systems: 0.5.4
-      bingo-testers: 0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)
+      bingo-testers: 0.5.7(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)
 
-  bingo-stratum@0.5.8(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11)(zod@3.24.2):
+  bingo-stratum@0.5.9(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12)(zod@3.24.2):
     dependencies:
       all-properties-lazy: 0.1.0
-      bingo: 0.5.11
-      bingo-fs: 0.5.4
+      bingo: 0.5.12
+      bingo-fs: 0.5.5
       bingo-systems: 0.5.4
       cached-factory: 0.1.0
       chalk: 5.4.1
@@ -5784,26 +5784,26 @@ snapshots:
 
   bingo-systems@0.5.4:
     dependencies:
-      bingo-fs: 0.5.4
+      bingo-fs: 0.5.5
       execa: 9.5.2
       get-github-auth-token: 0.1.1
       octokit: 4.1.2
 
-  bingo-testers@0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.11):
+  bingo-testers@0.5.7(bingo-fs@0.5.5)(bingo-systems@0.5.4)(bingo@0.5.12):
     dependencies:
       all-properties-lazy: 0.1.0
-      bingo: 0.5.11
-      bingo-fs: 0.5.4
+      bingo: 0.5.12
+      bingo-fs: 0.5.5
       bingo-systems: 0.5.4
       diff: 7.0.0
       octokit: 4.1.2
       without-undefined-properties: 0.1.1
 
-  bingo@0.5.11:
+  bingo@0.5.12:
     dependencies:
       '@clack/prompts': 0.10.0
       all-properties-lazy: 0.1.0
-      bingo-fs: 0.5.4
+      bingo-fs: 0.5.5
       bingo-requests: 0.5.5
       bingo-systems: 0.5.4
       cached-factory: 0.1.0
@@ -6999,20 +6999,20 @@ snapshots:
 
   ini@4.1.3: {}
 
-  input-from-file-json@0.5.4(bingo@0.5.11)(zod@3.24.2):
+  input-from-file-json@0.5.4(bingo@0.5.12)(zod@3.24.2):
     dependencies:
-      bingo: 0.5.11
-      input-from-file: 0.5.4(bingo@0.5.11)
+      bingo: 0.5.12
+      input-from-file: 0.5.4(bingo@0.5.12)
       zod: 3.24.2
 
-  input-from-file@0.5.4(bingo@0.5.11):
+  input-from-file@0.5.4(bingo@0.5.12):
     dependencies:
-      bingo: 0.5.11
+      bingo: 0.5.12
       zod: 3.24.2
 
-  input-from-script@0.5.4(bingo@0.5.11):
+  input-from-script@0.5.4(bingo@0.5.12):
     dependencies:
-      bingo: 0.5.11
+      bingo: 0.5.12
       zod: 3.24.2
 
   inquirer@12.3.0(@types/node@22.13.10):
diff --git a/src/blocks/blockCodecov.ts b/src/blocks/blockCodecov.ts
index e6e4700..0afbe38 100644
--- a/src/blocks/blockCodecov.ts
+++ b/src/blocks/blockCodecov.ts
@@ -1,3 +1,4 @@
+import _ from "lodash";
 import { z } from "zod";
 
 import { base } from "../base.js";
@@ -6,12 +7,46 @@ import { blockGitHubApps } from "./blockGitHubApps.js";
 import { blockREADME } from "./blockREADME.js";
 import { blockRemoveFiles } from "./blockRemoveFiles.js";
 import { blockVitest } from "./blockVitest.js";
+import { intakeFileAsYaml } from "./intake/intakeFileAsYaml.js";
+
+interface JobStep {
+	env?: Record<string, string>;
+	uses?: unknown;
+}
 
 export const blockCodecov = base.createBlock({
 	about: {
 		name: "Codecov",
 	},
-	addons: { env: z.record(z.string(), z.string()).optional() },
+	addons: {
+		env: z.record(z.string(), z.string()).optional(),
+	},
+	intake({ files }) {
+		const ciYaml = intakeFileAsYaml(files, [".github", "workflows", "ci.yml"]);
+		if (!ciYaml) {
+			return undefined;
+		}
+
+		const steps = _.get(ciYaml, ["jobs", "test", "steps"]) as
+			| JobStep[]
+			| undefined;
+		if (!steps) {
+			return undefined;
+		}
+
+		const step = steps.find(
+			(step) =>
+				typeof step.uses === "string" &&
+				step.uses.startsWith("codecov/codecov-action"),
+		);
+		if (!step) {
+			return undefined;
+		}
+
+		return {
+			env: step.env,
+		};
+	},
 	produce({ addons, options }) {
 		const { env } = addons;
 		return {
diff --git a/src/blocks/intake/intakeFile.ts b/src/blocks/intake/intakeFile.ts
new file mode 100644
index 0000000..a07bdf7
--- /dev/null
+++ b/src/blocks/intake/intakeFile.ts
@@ -0,0 +1,20 @@
+import { IntakeDirectory, IntakeFileEntry } from "bingo-fs";
+
+export function intakeFile(
+	files: IntakeDirectory,
+	filePath: string[],
+): IntakeFileEntry | undefined {
+	if (!filePath.length) {
+		return undefined;
+	}
+
+	const entry = files[filePath[0]];
+
+	if (filePath.length === 1) {
+		return Array.isArray(entry) ? entry : undefined;
+	}
+
+	return typeof entry === "object" && !Array.isArray(entry)
+		? intakeFile(entry, filePath.slice(1))
+		: undefined;
+}
diff --git a/src/blocks/intake/intakeFileAsYaml.ts b/src/blocks/intake/intakeFileAsYaml.ts
new file mode 100644
index 0000000..459be14
--- /dev/null
+++ b/src/blocks/intake/intakeFileAsYaml.ts
@@ -0,0 +1,14 @@
+import { IntakeDirectory } from "bingo-fs";
+import jsYaml from "js-yaml";
+
+import { intakeFile } from "./intakeFile.js";
+
+export function intakeFileAsYaml(files: IntakeDirectory, filePath: string[]) {
+	const file = intakeFile(files, filePath);
+
+	try {
+		return file && jsYaml.load(file[0]);
+	} catch {
+		return undefined;
+	}
+}


Test patch:
diff --git a/src/blocks/blockCodecov.test.ts b/src/blocks/blockCodecov.test.ts
index 7f18bba..b18a73b 100644
--- a/src/blocks/blockCodecov.test.ts
+++ b/src/blocks/blockCodecov.test.ts
@@ -1,5 +1,6 @@
-import { testBlock } from "bingo-stratum-testers";
-import { describe, expect, test } from "vitest";
+import { testBlock, testIntake } from "bingo-stratum-testers";
+import jsYaml from "js-yaml";
+import { describe, expect, it, test } from "vitest";
 
 import { blockCodecov } from "./blockCodecov.js";
 import { optionsBase } from "./options.fakes.js";
@@ -52,7 +53,7 @@ describe("blockCodecov", () => {
 		`);
 	});
 
-	test("transition mode", () => {
+	test("transition mode without files", () => {
 		const creation = testBlock(blockCodecov, {
 			mode: "transition",
 			options: optionsBase,
@@ -163,4 +164,140 @@ describe("blockCodecov", () => {
 			      	}
 			      `);
 	});
+
+	describe("intake", () => {
+		it("returns undefined when ci.yml does not exist", () => {
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when ci.yml contains invalid yml", () => {
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {
+							"ci.yml": ["invalid yml!"],
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when ci.yml does not contain a test job", () => {
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {
+							"ci.yml": [
+								jsYaml.dump({
+									jobs: {
+										other: {
+											name: "Other",
+											steps: [],
+										},
+									},
+								}),
+							],
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when ci.yml contains a test job with only non-string uses", () => {
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {
+							"ci.yml": [
+								jsYaml.dump({
+									jobs: {
+										test: {
+											name: "Test",
+											steps: [
+												{
+													uses: { not: "a string" },
+												},
+											],
+										},
+									},
+								}),
+							],
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined env when ci.yml contains a test job with no env in its codecov step", () => {
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {
+							"ci.yml": [
+								jsYaml.dump({
+									jobs: {
+										test: {
+											name: "Test",
+											steps: [
+												{
+													uses: "codecov/codecov-action@v3",
+												},
+											],
+										},
+									},
+								}),
+							],
+						},
+					},
+				},
+			});
+
+			expect(actual).toEqual({ env: undefined });
+		});
+
+		it("returns env when ci.yml contains a test job with env in its codecov step", () => {
+			const env = {
+				CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}",
+			};
+			const actual = testIntake(blockCodecov, {
+				files: {
+					".github": {
+						workflows: {
+							"ci.yml": [
+								jsYaml.dump({
+									jobs: {
+										test: {
+											name: "Test",
+											steps: [
+												{
+													env,
+													uses: "codecov/codecov-action@v3",
+												},
+											],
+										},
+									},
+								}),
+							],
+						},
+					},
+				},
+			});
+
+			expect(actual).toEqual({ env });
+		});
+	});
 });
diff --git a/src/blocks/intake/intakeFile.test.ts b/src/blocks/intake/intakeFile.test.ts
new file mode 100644
index 0000000..d7e0928
--- /dev/null
+++ b/src/blocks/intake/intakeFile.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+
+import { intakeFile } from "./intakeFile.js";
+
+describe(intakeFile, () => {
+	it("returns undefined when filePath is empty", () => {
+		const actual = intakeFile(
+			{
+				src: {},
+			},
+			[],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns undefined when filePath is not found", () => {
+		const actual = intakeFile(
+			{
+				src: {},
+			},
+			["lib"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns the file when filePath points to a root-level file", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				"file.txt": [value],
+			},
+			["file.txt"],
+		);
+
+		expect(actual).toEqual([value]);
+	});
+
+	it("returns the file when filePath points to a file in a directory", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": [value],
+				},
+			},
+			["src", "file.txt"],
+		);
+
+		expect(actual).toEqual([value]);
+	});
+});
diff --git a/src/blocks/intake/intakeFileAsYaml.test.ts b/src/blocks/intake/intakeFileAsYaml.test.ts
new file mode 100644
index 0000000..3ad158f
--- /dev/null
+++ b/src/blocks/intake/intakeFileAsYaml.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { intakeFileAsYaml } from "./intakeFileAsYaml.js";
+
+describe(intakeFileAsYaml, () => {
+	it("returns undefined when the file does not exist", () => {
+		const actual = intakeFileAsYaml({}, []);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns undefined when the file does not have valid yml", () => {
+		const actual = intakeFileAsYaml(
+			{
+				"file.yml": ["{"],
+			},
+			["file.yml"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns loaded file contents when the file has valid yml", () => {
+		const actual = intakeFileAsYaml(
+			{
+				"file.yml": ["key: value"],
+			},
+			["file.yml"],
+		);
+
+		expect(actual).toEqual({ key: "value" });
+	});
+});