From af38d1aea5f48b54dfb27bf6dd01b43d8d0c0edc Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 3 Oct 2023 14:41:43 +0200 Subject: [PATCH 1/4] feat: add opt-in --preserve-generated-from option --- docs/Options.md | 1 + src/shared/options/args.ts | 1 + .../options/createRepositoryWithApi.test.ts | 71 +++++++++++++++++++ src/shared/options/createRepositoryWithApi.ts | 35 +++++++++ .../options/ensureRepositoryExists.test.ts | 36 +++++----- src/shared/options/ensureRepositoryExists.ts | 12 ++-- src/shared/options/optionsSchema.ts | 1 + src/shared/options/readOptions.test.ts | 52 ++++++++++++++ src/shared/options/readOptions.ts | 2 + src/shared/types.ts | 1 + src/steps/updateReadme.test.ts | 22 ++++-- src/steps/updateReadme.ts | 11 +-- src/steps/writeReadme/index.test.ts | 12 +--- src/steps/writeReadme/index.ts | 8 +-- 14 files changed, 219 insertions(+), 46 deletions(-) create mode 100644 src/shared/options/createRepositoryWithApi.test.ts create mode 100644 src/shared/options/createRepositoryWithApi.ts diff --git a/docs/Options.md b/docs/Options.md index 78d08ab38..0ef3dc88a 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -61,6 +61,7 @@ The setup scripts also allow for optional overrides of the following inputs whos - This can be specified any number of times, like `--keywords apple --keywords "banana cherry"` - `--logo` _(`string`)_: Local image file in the repository to display near the top of the README.md as a logo - `--logo-alt` _(`string`)_: If `--logo` is provided or detected from an existing README.md, alt text that describes the image will be prompted for if not provided +- `--preserve-generated-from` _(`boolean`)_: Whether to keep the GitHub repository _generated from_ notice (by default, `false`) For example, customizing the ownership and users associated with a new repository: diff --git a/src/shared/options/args.ts b/src/shared/options/args.ts index ffdac01d0..cb185fd5c 100644 --- a/src/shared/options/args.ts +++ b/src/shared/options/args.ts @@ -35,6 +35,7 @@ export const allArgOptions = { mode: { type: "string" }, offline: { type: "boolean" }, owner: { type: "string" }, + "preserve-generated-from": { type: "boolean" }, repository: { type: "string" }, "skip-all-contributors-api": { type: "boolean" }, "skip-github-api": { type: "boolean" }, diff --git a/src/shared/options/createRepositoryWithApi.test.ts b/src/shared/options/createRepositoryWithApi.test.ts new file mode 100644 index 000000000..5f4ca68e9 --- /dev/null +++ b/src/shared/options/createRepositoryWithApi.test.ts @@ -0,0 +1,71 @@ +import { Octokit } from "octokit"; +import { describe, expect, it, vi } from "vitest"; + +import { createRepositoryWithApi } from "./createRepositoryWithApi.js"; + +const options = { owner: "StubOwner", repository: "stub-repository" }; + +const mockCreateUsingTemplate = vi.fn(); +const mockCreateInOrg = vi.fn(); +const mockCreateForAuthenticatedUser = vi.fn(); +const mockGetAuthenticated = vi.fn(); + +const createMockOctokit = () => + ({ + rest: { + repos: { + createForAuthenticatedUser: mockCreateForAuthenticatedUser, + createInOrg: mockCreateInOrg, + createUsingTemplate: mockCreateUsingTemplate, + }, + users: { + getAuthenticated: mockGetAuthenticated, + }, + }, + }) as unknown as Octokit; + +describe("createRepositoryWithApi", () => { + it("creates using a template when preserveGeneratedFrom is true", async () => { + await createRepositoryWithApi(createMockOctokit(), { + ...options, + preserveGeneratedFrom: true, + }); + + expect(mockCreateForAuthenticatedUser).not.toHaveBeenCalled(); + expect(mockCreateInOrg).not.toHaveBeenCalled(); + expect(mockCreateUsingTemplate).toHaveBeenCalledWith({ + name: options.repository, + owner: options.owner, + template_owner: "JoshuaKGoldberg", + template_repo: "create-typescript-app", + }); + }); + + it("creates under the user when the user is the owner", async () => { + mockGetAuthenticated.mockResolvedValueOnce({ + data: { + login: options.owner, + }, + }); + await createRepositoryWithApi(createMockOctokit(), options); + + expect(mockCreateForAuthenticatedUser).toHaveBeenCalledWith({ + name: options.repository, + }); + expect(mockCreateInOrg).not.toHaveBeenCalled(); + expect(mockCreateUsingTemplate).not.toHaveBeenCalled(); + }); + + it("creates under an org when the user is not the owner", async () => { + const login = "other-user"; + mockGetAuthenticated.mockResolvedValueOnce({ data: { login } }); + await createRepositoryWithApi(createMockOctokit(), options); + + expect(mockCreateForAuthenticatedUser).not.toHaveBeenCalled(); + expect(mockCreateInOrg).toHaveBeenCalledWith({ + name: options.repository, + org: options.owner, + }); + expect(mockCreateUsingTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/options/createRepositoryWithApi.ts b/src/shared/options/createRepositoryWithApi.ts new file mode 100644 index 000000000..1a7a5bd1a --- /dev/null +++ b/src/shared/options/createRepositoryWithApi.ts @@ -0,0 +1,35 @@ +import { Octokit } from "octokit"; + +export interface CreateRepositoryWithApiOptions { + owner: string; + preserveGeneratedFrom?: boolean; + repository: string; +} + +export async function createRepositoryWithApi( + octokit: Octokit, + options: CreateRepositoryWithApiOptions, +) { + if (options.preserveGeneratedFrom) { + await octokit.rest.repos.createUsingTemplate({ + name: options.repository, + owner: options.owner, + template_owner: "JoshuaKGoldberg", + template_repo: "create-typescript-app", + }); + return; + } + + 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, + }); + } +} diff --git a/src/shared/options/ensureRepositoryExists.test.ts b/src/shared/options/ensureRepositoryExists.test.ts index 0e86ebfaf..62de3f05b 100644 --- a/src/shared/options/ensureRepositoryExists.test.ts +++ b/src/shared/options/ensureRepositoryExists.test.ts @@ -30,10 +30,15 @@ const auth = "abc123"; const owner = "StubOwner"; const repository = "stub-repository"; -const createUsingTemplate = vi.fn(); +const mockCreateRepositoryWithApi = vi.fn(); -const createMockOctokit = () => - ({ rest: { repos: { createUsingTemplate } } }) as unknown as Octokit; +vi.mock("./createRepositoryWithApi.js", () => ({ + get createRepositoryWithApi() { + return mockCreateRepositoryWithApi; + }, +})); + +const createMockOctokit = () => ({}) as unknown as Octokit; describe("ensureRepositoryExists", () => { it("returns the repository when octokit is undefined", async () => { @@ -73,11 +78,10 @@ describe("ensureRepositoryExists", () => { ); expect(actual).toEqual({ github: { auth, octokit }, repository }); - expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({ - name: repository, + expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, { owner, - template_owner: "JoshuaKGoldberg", - template_repo: "create-typescript-app", + preserveGeneratedFrom: undefined, + repository, }); }); @@ -92,11 +96,10 @@ describe("ensureRepositoryExists", () => { ); expect(actual).toEqual({ github: { auth, octokit }, repository }); - expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({ - name: repository, + expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, { owner, - template_owner: "JoshuaKGoldberg", - template_repo: "create-typescript-app", + preserveGeneratedFrom: undefined, + repository, }); expect(mockSelect).not.toHaveBeenCalled(); }); @@ -120,7 +123,7 @@ describe("ensureRepositoryExists", () => { github: { auth, octokit }, repository: newRepository, }); - expect(octokit.rest.repos.createUsingTemplate).not.toHaveBeenCalled(); + expect(mockCreateRepositoryWithApi).not.toHaveBeenCalled(); }); it("creates the second repository when the prompt is 'different', the first repository does not exist, and the second repository does not exist", async () => { @@ -142,11 +145,10 @@ describe("ensureRepositoryExists", () => { github: { auth, octokit }, repository: newRepository, }); - expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({ - name: newRepository, + expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, { owner, - template_owner: "JoshuaKGoldberg", - template_repo: "create-typescript-app", + preserveGeneratedFrom: undefined, + repository: newRepository, }); }); @@ -162,6 +164,6 @@ describe("ensureRepositoryExists", () => { ); expect(actual).toEqual({ octokit: undefined, repository }); - expect(octokit.rest.repos.createUsingTemplate).not.toHaveBeenCalled(); + expect(mockCreateRepositoryWithApi).not.toHaveBeenCalled(); }); }); diff --git a/src/shared/options/ensureRepositoryExists.ts b/src/shared/options/ensureRepositoryExists.ts index 2914c0afa..2eebaa21d 100644 --- a/src/shared/options/ensureRepositoryExists.ts +++ b/src/shared/options/ensureRepositoryExists.ts @@ -3,11 +3,12 @@ import * as prompts from "@clack/prompts"; import { doesRepositoryExist } from "../doesRepositoryExist.js"; import { filterPromptCancel } from "../prompts.js"; import { Options } from "../types.js"; +import { createRepositoryWithApi } from "./createRepositoryWithApi.js"; import { GitHub } from "./getGitHub.js"; export type EnsureRepositoryExistsOptions = Pick< Options, - "mode" | "owner" | "repository" + "mode" | "owner" | "preserveGeneratedFrom" | "repository" >; export interface RepositoryExistsResult { @@ -58,13 +59,12 @@ export async function ensureRepositoryExists( return {}; case "create": - await github.octokit.rest.repos.createUsingTemplate({ - name: repository, + await createRepositoryWithApi(github.octokit, { owner: options.owner, - template_owner: "JoshuaKGoldberg", - template_repo: "create-typescript-app", + preserveGeneratedFrom: options.preserveGeneratedFrom, + repository, }); - return { github: github, repository }; + return { github, repository }; case "different": const newRepository = filterPromptCancel( diff --git a/src/shared/options/optionsSchema.ts b/src/shared/options/optionsSchema.ts index 2065e4d1c..22687b89b 100644 --- a/src/shared/options/optionsSchema.ts +++ b/src/shared/options/optionsSchema.ts @@ -47,6 +47,7 @@ export const optionsSchemaShape = { .optional(), offline: z.boolean().optional(), owner: z.string().optional(), + preserveGeneratedFrom: z.boolean().optional(), repository: z.string().optional(), skipAllContributorsApi: z.boolean().optional(), skipGitHubApi: z.boolean().optional(), diff --git a/src/shared/options/readOptions.test.ts b/src/shared/options/readOptions.test.ts index 3b34b8c49..d483672d3 100644 --- a/src/shared/options/readOptions.test.ts +++ b/src/shared/options/readOptions.test.ts @@ -33,6 +33,7 @@ const emptyOptions = { funding: undefined, offline: undefined, owner: undefined, + preserveGeneratedFrom: false, repository: undefined, skipAllContributorsApi: undefined, skipGitHubApi: undefined, @@ -319,6 +320,57 @@ describe("readOptions", () => { }); }); + it("defaults preserveGeneratedFrom to false when the owner is not JoshuaKGoldberg", async () => { + mockAugmentOptionsWithExcludes.mockImplementationOnce( + (options: Partial) => ({ + ...options, + ...mockOptions, + }), + ); + mockEnsureRepositoryExists.mockResolvedValue({ + github: mockOptions.github, + repository: mockOptions.repository, + }); + mockGetPrefillOrPromptedOption.mockImplementation(() => "mock"); + + expect( + await readOptions(["--base", mockOptions.base], "create"), + ).toStrictEqual({ + cancelled: false, + github: mockOptions.github, + options: expect.objectContaining({ + preserveGeneratedFrom: false, + }), + }); + }); + + it("defaults preserveGeneratedFrom to true when the owner is JoshuaKGoldberg", async () => { + mockAugmentOptionsWithExcludes.mockImplementationOnce( + (options: Partial) => ({ + ...options, + ...mockOptions, + }), + ); + mockEnsureRepositoryExists.mockResolvedValue({ + github: mockOptions.github, + repository: mockOptions.repository, + }); + mockGetPrefillOrPromptedOption.mockImplementation(() => "mock"); + + expect( + await readOptions( + ["--base", mockOptions.base, "--owner", "JoshuaKGoldberg"], + "create", + ), + ).toStrictEqual({ + cancelled: false, + github: mockOptions.github, + options: expect.objectContaining({ + preserveGeneratedFrom: true, + }), + }); + }); + it("skips API calls when --offline is true", async () => { mockAugmentOptionsWithExcludes.mockImplementation((options: Options) => ({ ...emptyOptions, diff --git a/src/shared/options/readOptions.ts b/src/shared/options/readOptions.ts index d2fc9904d..eaa9011ca 100644 --- a/src/shared/options/readOptions.ts +++ b/src/shared/options/readOptions.ts @@ -78,6 +78,8 @@ export async function readOptions( funding: values.funding, offline: values.offline, owner: values.owner, + preserveGeneratedFrom: + values["preserve-generated-from"] ?? values.owner === "JoshuaKGoldberg", repository: values.repository, skipAllContributorsApi: values["skip-all-contributors-api"] ?? values.offline, diff --git a/src/shared/types.ts b/src/shared/types.ts index 51ff71fb0..77b3d3156 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -70,6 +70,7 @@ export interface Options { mode: Mode; offline?: boolean; owner: string; + preserveGeneratedFrom?: boolean; repository: string; skipAllContributorsApi?: boolean; skipGitHubApi?: boolean; diff --git a/src/steps/updateReadme.test.ts b/src/steps/updateReadme.test.ts index 9696a5699..94f27f9d4 100644 --- a/src/steps/updateReadme.test.ts +++ b/src/steps/updateReadme.test.ts @@ -33,17 +33,29 @@ describe("updateReadme", () => { " - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + > 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). ", ], ] `); }); - it("doesn't adds a notice when the file contains it already", async () => { - mockReadFileSafe.mockResolvedValue( - "", - ); + it("doesn't add a notice when the file contains it already", async () => { + mockReadFileSafe.mockResolvedValue(` + + + > 💙 This package was templated using [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + `); + + await updateReadme(); + + expect(mockAppendFile.mock.calls).toMatchInlineSnapshot("[]"); + }); + + it("doesn't add a notice when the file contains an older version of it already", async () => { + mockReadFileSafe.mockResolvedValue(` + 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + `); await updateReadme(); diff --git a/src/steps/updateReadme.ts b/src/steps/updateReadme.ts index a9a868890..be75b7b82 100644 --- a/src/steps/updateReadme.ts +++ b/src/steps/updateReadme.ts @@ -3,20 +3,21 @@ import { EOL } from "node:os"; import { readFileSafe } from "../shared/readFileSafe.js"; -const detectionLine = ``; - export const endOfReadmeNotice = [ ``, - detectionLine, + ``, ``, - `> 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, + `> 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, ``, ].join(EOL); +export const endOfReadmeMatcher = + /💙.+(?:based|built|templated).+(?:from|using|on|with).+create-typescript-app/; + export async function updateReadme() { const contents = await readFileSafe("./README.md", ""); - if (!contents.includes(detectionLine)) { + if (!endOfReadmeMatcher.test(contents)) { await fs.appendFile("./README.md", endOfReadmeNotice); } } diff --git a/src/steps/writeReadme/index.test.ts b/src/steps/writeReadme/index.test.ts index 5f214361e..612ddb6ea 100644 --- a/src/steps/writeReadme/index.test.ts +++ b/src/steps/writeReadme/index.test.ts @@ -122,10 +122,9 @@ describe("writeReadme", () => { - - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + > 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). ", ], ] @@ -194,10 +193,9 @@ describe("writeReadme", () => { - - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + > 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). ", ], ] @@ -269,10 +267,9 @@ describe("writeReadme", () => { - - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). + > 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). ", ], ] @@ -321,8 +318,6 @@ describe("writeReadme", () => { - - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). @@ -391,7 +386,6 @@ describe("writeReadme", () => { - > 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app). diff --git a/src/steps/writeReadme/index.ts b/src/steps/writeReadme/index.ts index 26af70de3..709a11822 100644 --- a/src/steps/writeReadme/index.ts +++ b/src/steps/writeReadme/index.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { readFileSafe } from "../../shared/readFileSafe.js"; import { Options } from "../../shared/types.js"; -import { endOfReadmeNotice } from "../updateReadme.js"; +import { endOfReadmeMatcher, endOfReadmeNotice } from "../updateReadme.js"; import { findExistingBadges } from "./findExistingBadges.js"; import { findIntroSectionClose } from "./findIntroSectionClose.js"; import { generateTopContent } from "./generateTopContent.js"; @@ -41,7 +41,7 @@ export async function writeReadme(options: Options) { [ generateTopContent(options, []), allContributorsContent, - endOfReadmeNotice, + endOfReadmeNotice.slice(1), ] .filter(Boolean) .join("\n\n"), @@ -65,8 +65,8 @@ export async function writeReadme(options: Options) { contents = [contents, allContributorsContent].join("\n\n"); } - if (!contents.includes(endOfReadmeNotice)) { - contents = [contents, endOfReadmeNotice].join("\n\n"); + if (!endOfReadmeMatcher.test(contents)) { + contents = [contents, endOfReadmeNotice].join("\n"); } await fs.writeFile("README.md", contents); From c0dcb5f9c9d70deb46c52d2264716ff8de6d6322 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 3 Oct 2023 14:52:06 +0200 Subject: [PATCH 2/4] Update initialization script --- script/initialize-test-e2e.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/initialize-test-e2e.js b/script/initialize-test-e2e.js index 3d4350eca..affb4c9ac 100644 --- a/script/initialize-test-e2e.js +++ b/script/initialize-test-e2e.js @@ -30,7 +30,7 @@ for (const search of [`/JoshuaKGoldberg/`, "create-typescript-app"]) { const { stdout } = await $`grep -i ${search} ${files}`; assert.equal( stdout, - `README.md:> 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, + `README.md:> 💙 This package was templated with [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, ); } From 80f4041f712ffc2388f90af9565f652adc387e28 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 3 Oct 2023 14:54:57 +0200 Subject: [PATCH 3/4] oop, the other thing --- script/initialize-test-e2e.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/initialize-test-e2e.js b/script/initialize-test-e2e.js index affb4c9ac..6acefd4a6 100644 --- a/script/initialize-test-e2e.js +++ b/script/initialize-test-e2e.js @@ -30,7 +30,7 @@ for (const search of [`/JoshuaKGoldberg/`, "create-typescript-app"]) { const { stdout } = await $`grep -i ${search} ${files}`; assert.equal( stdout, - `README.md:> 💙 This package was templated with [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, + `README.md:> 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`, ); } From 59ad618b3ad6de134d409e44ec8bcf575b30ba71 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 3 Oct 2023 15:02:43 +0200 Subject: [PATCH 4/4] While I'm here, one more readOptions test --- src/shared/options/readOptions.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/shared/options/readOptions.test.ts b/src/shared/options/readOptions.test.ts index d483672d3..410d33180 100644 --- a/src/shared/options/readOptions.test.ts +++ b/src/shared/options/readOptions.test.ts @@ -320,6 +320,25 @@ describe("readOptions", () => { }); }); + it("returns cancelled options when augmentOptionsWithExcludes returns undefined", async () => { + mockAugmentOptionsWithExcludes.mockResolvedValue(undefined); + mockGetPrefillOrPromptedOption.mockImplementation(() => "mock"); + + expect( + await readOptions(["--base", mockOptions.base], "create"), + ).toStrictEqual({ + cancelled: true, + options: { + ...emptyOptions, + base: mockOptions.base, + description: "mock", + owner: "mock", + repository: "mock", + title: "mock", + }, + }); + }); + it("defaults preserveGeneratedFrom to false when the owner is not JoshuaKGoldberg", async () => { mockAugmentOptionsWithExcludes.mockImplementationOnce( (options: Partial) => ({