Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add opt-in --preserve-generated-from option #940

Merged
merged 4 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion script/initialize-test-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`,
);
}

Expand Down
1 change: 1 addition & 0 deletions src/shared/options/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
71 changes: 71 additions & 0 deletions src/shared/options/createRepositoryWithApi.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
35 changes: 35 additions & 0 deletions src/shared/options/createRepositoryWithApi.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
36 changes: 19 additions & 17 deletions src/shared/options/ensureRepositoryExists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
});
});

Expand All @@ -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();
});
Expand All @@ -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 () => {
Expand All @@ -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,
});
});

Expand All @@ -162,6 +164,6 @@ describe("ensureRepositoryExists", () => {
);

expect(actual).toEqual({ octokit: undefined, repository });
expect(octokit.rest.repos.createUsingTemplate).not.toHaveBeenCalled();
expect(mockCreateRepositoryWithApi).not.toHaveBeenCalled();
});
});
12 changes: 6 additions & 6 deletions src/shared/options/ensureRepositoryExists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/shared/options/optionsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
71 changes: 71 additions & 0 deletions src/shared/options/readOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const emptyOptions = {
funding: undefined,
offline: undefined,
owner: undefined,
preserveGeneratedFrom: false,
repository: undefined,
skipAllContributorsApi: undefined,
skipGitHubApi: undefined,
Expand Down Expand Up @@ -319,6 +320,76 @@ 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<Options>) => ({
...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>) => ({
...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,
Expand Down
2 changes: 2 additions & 0 deletions src/shared/options/readOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface Options {
mode: Mode;
offline?: boolean;
owner: string;
preserveGeneratedFrom?: boolean;
repository: string;
skipAllContributorsApi?: boolean;
skipGitHubApi?: boolean;
Expand Down
22 changes: 17 additions & 5 deletions src/steps/updateReadme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,29 @@ describe("updateReadme", () => {
"
<!-- You can remove this notice if you don't want it 🙂 no worries! -->

> 💙 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(
"<!-- You can remove this notice if you don't want it 🙂 no worries! -->",
);
it("doesn't add a notice when the file contains it already", async () => {
mockReadFileSafe.mockResolvedValue(`
<!-- You can remove this notice if you don't want it 🙂 no worries! -->

> 💙 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();

Expand Down
Loading