Skip to content

Commit

Permalink
feat: add opt-in --preserve-generated-from option (#940)
Browse files Browse the repository at this point in the history
## PR Checklist

- [x] Addresses an existing open issue: fixes #913
- [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

GitHub doesn't have a unified _"create a new blank repository"_ API. So
unless the new `--preserve-generated-from` option is true, repo creation
will check whether the authenticated user has the same login as the
`--owner` option, then use either `createForAuthenticatedUser` or
`createInOrg.

Slightly rephrases the end-of-README notice while I'm here to remove my
username from it. And in doing so, expands the _"does the notice already
exist?"_ logic to check previous variants.
  • Loading branch information
JoshuaKGoldberg committed Oct 3, 2023
1 parent 766a735 commit fc1eda7
Show file tree
Hide file tree
Showing 15 changed files with 239 additions and 47 deletions.
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

0 comments on commit fc1eda7

Please sign in to comment.