Skip to content

Commit af38d1a

Browse files
feat: add opt-in --preserve-generated-from option
1 parent 766a735 commit af38d1a

14 files changed

+219
-46
lines changed

Diff for: docs/Options.md

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The setup scripts also allow for optional overrides of the following inputs whos
6161
- This can be specified any number of times, like `--keywords apple --keywords "banana cherry"`
6262
- `--logo` _(`string`)_: Local image file in the repository to display near the top of the README.md as a logo
6363
- `--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
64+
- `--preserve-generated-from` _(`boolean`)_: Whether to keep the GitHub repository _generated from_ notice (by default, `false`)
6465

6566
For example, customizing the ownership and users associated with a new repository:
6667

Diff for: src/shared/options/args.ts

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const allArgOptions = {
3535
mode: { type: "string" },
3636
offline: { type: "boolean" },
3737
owner: { type: "string" },
38+
"preserve-generated-from": { type: "boolean" },
3839
repository: { type: "string" },
3940
"skip-all-contributors-api": { type: "boolean" },
4041
"skip-github-api": { type: "boolean" },

Diff for: src/shared/options/createRepositoryWithApi.test.ts

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Octokit } from "octokit";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { createRepositoryWithApi } from "./createRepositoryWithApi.js";
5+
6+
const options = { owner: "StubOwner", repository: "stub-repository" };
7+
8+
const mockCreateUsingTemplate = vi.fn();
9+
const mockCreateInOrg = vi.fn();
10+
const mockCreateForAuthenticatedUser = vi.fn();
11+
const mockGetAuthenticated = vi.fn();
12+
13+
const createMockOctokit = () =>
14+
({
15+
rest: {
16+
repos: {
17+
createForAuthenticatedUser: mockCreateForAuthenticatedUser,
18+
createInOrg: mockCreateInOrg,
19+
createUsingTemplate: mockCreateUsingTemplate,
20+
},
21+
users: {
22+
getAuthenticated: mockGetAuthenticated,
23+
},
24+
},
25+
}) as unknown as Octokit;
26+
27+
describe("createRepositoryWithApi", () => {
28+
it("creates using a template when preserveGeneratedFrom is true", async () => {
29+
await createRepositoryWithApi(createMockOctokit(), {
30+
...options,
31+
preserveGeneratedFrom: true,
32+
});
33+
34+
expect(mockCreateForAuthenticatedUser).not.toHaveBeenCalled();
35+
expect(mockCreateInOrg).not.toHaveBeenCalled();
36+
expect(mockCreateUsingTemplate).toHaveBeenCalledWith({
37+
name: options.repository,
38+
owner: options.owner,
39+
template_owner: "JoshuaKGoldberg",
40+
template_repo: "create-typescript-app",
41+
});
42+
});
43+
44+
it("creates under the user when the user is the owner", async () => {
45+
mockGetAuthenticated.mockResolvedValueOnce({
46+
data: {
47+
login: options.owner,
48+
},
49+
});
50+
await createRepositoryWithApi(createMockOctokit(), options);
51+
52+
expect(mockCreateForAuthenticatedUser).toHaveBeenCalledWith({
53+
name: options.repository,
54+
});
55+
expect(mockCreateInOrg).not.toHaveBeenCalled();
56+
expect(mockCreateUsingTemplate).not.toHaveBeenCalled();
57+
});
58+
59+
it("creates under an org when the user is not the owner", async () => {
60+
const login = "other-user";
61+
mockGetAuthenticated.mockResolvedValueOnce({ data: { login } });
62+
await createRepositoryWithApi(createMockOctokit(), options);
63+
64+
expect(mockCreateForAuthenticatedUser).not.toHaveBeenCalled();
65+
expect(mockCreateInOrg).toHaveBeenCalledWith({
66+
name: options.repository,
67+
org: options.owner,
68+
});
69+
expect(mockCreateUsingTemplate).not.toHaveBeenCalled();
70+
});
71+
});

Diff for: src/shared/options/createRepositoryWithApi.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Octokit } from "octokit";
2+
3+
export interface CreateRepositoryWithApiOptions {
4+
owner: string;
5+
preserveGeneratedFrom?: boolean;
6+
repository: string;
7+
}
8+
9+
export async function createRepositoryWithApi(
10+
octokit: Octokit,
11+
options: CreateRepositoryWithApiOptions,
12+
) {
13+
if (options.preserveGeneratedFrom) {
14+
await octokit.rest.repos.createUsingTemplate({
15+
name: options.repository,
16+
owner: options.owner,
17+
template_owner: "JoshuaKGoldberg",
18+
template_repo: "create-typescript-app",
19+
});
20+
return;
21+
}
22+
23+
const currentUser = await octokit.rest.users.getAuthenticated();
24+
25+
if (currentUser.data.login === options.owner) {
26+
await octokit.rest.repos.createForAuthenticatedUser({
27+
name: options.repository,
28+
});
29+
} else {
30+
await octokit.rest.repos.createInOrg({
31+
name: options.repository,
32+
org: options.owner,
33+
});
34+
}
35+
}

Diff for: src/shared/options/ensureRepositoryExists.test.ts

+19-17
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,15 @@ const auth = "abc123";
3030
const owner = "StubOwner";
3131
const repository = "stub-repository";
3232

33-
const createUsingTemplate = vi.fn();
33+
const mockCreateRepositoryWithApi = vi.fn();
3434

35-
const createMockOctokit = () =>
36-
({ rest: { repos: { createUsingTemplate } } }) as unknown as Octokit;
35+
vi.mock("./createRepositoryWithApi.js", () => ({
36+
get createRepositoryWithApi() {
37+
return mockCreateRepositoryWithApi;
38+
},
39+
}));
40+
41+
const createMockOctokit = () => ({}) as unknown as Octokit;
3742

3843
describe("ensureRepositoryExists", () => {
3944
it("returns the repository when octokit is undefined", async () => {
@@ -73,11 +78,10 @@ describe("ensureRepositoryExists", () => {
7378
);
7479

7580
expect(actual).toEqual({ github: { auth, octokit }, repository });
76-
expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({
77-
name: repository,
81+
expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, {
7882
owner,
79-
template_owner: "JoshuaKGoldberg",
80-
template_repo: "create-typescript-app",
83+
preserveGeneratedFrom: undefined,
84+
repository,
8185
});
8286
});
8387

@@ -92,11 +96,10 @@ describe("ensureRepositoryExists", () => {
9296
);
9397

9498
expect(actual).toEqual({ github: { auth, octokit }, repository });
95-
expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({
96-
name: repository,
99+
expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, {
97100
owner,
98-
template_owner: "JoshuaKGoldberg",
99-
template_repo: "create-typescript-app",
101+
preserveGeneratedFrom: undefined,
102+
repository,
100103
});
101104
expect(mockSelect).not.toHaveBeenCalled();
102105
});
@@ -120,7 +123,7 @@ describe("ensureRepositoryExists", () => {
120123
github: { auth, octokit },
121124
repository: newRepository,
122125
});
123-
expect(octokit.rest.repos.createUsingTemplate).not.toHaveBeenCalled();
126+
expect(mockCreateRepositoryWithApi).not.toHaveBeenCalled();
124127
});
125128

126129
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", () => {
142145
github: { auth, octokit },
143146
repository: newRepository,
144147
});
145-
expect(octokit.rest.repos.createUsingTemplate).toHaveBeenCalledWith({
146-
name: newRepository,
148+
expect(mockCreateRepositoryWithApi).toHaveBeenCalledWith(octokit, {
147149
owner,
148-
template_owner: "JoshuaKGoldberg",
149-
template_repo: "create-typescript-app",
150+
preserveGeneratedFrom: undefined,
151+
repository: newRepository,
150152
});
151153
});
152154

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

164166
expect(actual).toEqual({ octokit: undefined, repository });
165-
expect(octokit.rest.repos.createUsingTemplate).not.toHaveBeenCalled();
167+
expect(mockCreateRepositoryWithApi).not.toHaveBeenCalled();
166168
});
167169
});

Diff for: src/shared/options/ensureRepositoryExists.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import * as prompts from "@clack/prompts";
33
import { doesRepositoryExist } from "../doesRepositoryExist.js";
44
import { filterPromptCancel } from "../prompts.js";
55
import { Options } from "../types.js";
6+
import { createRepositoryWithApi } from "./createRepositoryWithApi.js";
67
import { GitHub } from "./getGitHub.js";
78

89
export type EnsureRepositoryExistsOptions = Pick<
910
Options,
10-
"mode" | "owner" | "repository"
11+
"mode" | "owner" | "preserveGeneratedFrom" | "repository"
1112
>;
1213

1314
export interface RepositoryExistsResult {
@@ -58,13 +59,12 @@ export async function ensureRepositoryExists(
5859
return {};
5960

6061
case "create":
61-
await github.octokit.rest.repos.createUsingTemplate({
62-
name: repository,
62+
await createRepositoryWithApi(github.octokit, {
6363
owner: options.owner,
64-
template_owner: "JoshuaKGoldberg",
65-
template_repo: "create-typescript-app",
64+
preserveGeneratedFrom: options.preserveGeneratedFrom,
65+
repository,
6666
});
67-
return { github: github, repository };
67+
return { github, repository };
6868

6969
case "different":
7070
const newRepository = filterPromptCancel(

Diff for: src/shared/options/optionsSchema.ts

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const optionsSchemaShape = {
4747
.optional(),
4848
offline: z.boolean().optional(),
4949
owner: z.string().optional(),
50+
preserveGeneratedFrom: z.boolean().optional(),
5051
repository: z.string().optional(),
5152
skipAllContributorsApi: z.boolean().optional(),
5253
skipGitHubApi: z.boolean().optional(),

Diff for: src/shared/options/readOptions.test.ts

+52
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const emptyOptions = {
3333
funding: undefined,
3434
offline: undefined,
3535
owner: undefined,
36+
preserveGeneratedFrom: false,
3637
repository: undefined,
3738
skipAllContributorsApi: undefined,
3839
skipGitHubApi: undefined,
@@ -319,6 +320,57 @@ describe("readOptions", () => {
319320
});
320321
});
321322

323+
it("defaults preserveGeneratedFrom to false when the owner is not JoshuaKGoldberg", async () => {
324+
mockAugmentOptionsWithExcludes.mockImplementationOnce(
325+
(options: Partial<Options>) => ({
326+
...options,
327+
...mockOptions,
328+
}),
329+
);
330+
mockEnsureRepositoryExists.mockResolvedValue({
331+
github: mockOptions.github,
332+
repository: mockOptions.repository,
333+
});
334+
mockGetPrefillOrPromptedOption.mockImplementation(() => "mock");
335+
336+
expect(
337+
await readOptions(["--base", mockOptions.base], "create"),
338+
).toStrictEqual({
339+
cancelled: false,
340+
github: mockOptions.github,
341+
options: expect.objectContaining({
342+
preserveGeneratedFrom: false,
343+
}),
344+
});
345+
});
346+
347+
it("defaults preserveGeneratedFrom to true when the owner is JoshuaKGoldberg", async () => {
348+
mockAugmentOptionsWithExcludes.mockImplementationOnce(
349+
(options: Partial<Options>) => ({
350+
...options,
351+
...mockOptions,
352+
}),
353+
);
354+
mockEnsureRepositoryExists.mockResolvedValue({
355+
github: mockOptions.github,
356+
repository: mockOptions.repository,
357+
});
358+
mockGetPrefillOrPromptedOption.mockImplementation(() => "mock");
359+
360+
expect(
361+
await readOptions(
362+
["--base", mockOptions.base, "--owner", "JoshuaKGoldberg"],
363+
"create",
364+
),
365+
).toStrictEqual({
366+
cancelled: false,
367+
github: mockOptions.github,
368+
options: expect.objectContaining({
369+
preserveGeneratedFrom: true,
370+
}),
371+
});
372+
});
373+
322374
it("skips API calls when --offline is true", async () => {
323375
mockAugmentOptionsWithExcludes.mockImplementation((options: Options) => ({
324376
...emptyOptions,

Diff for: src/shared/options/readOptions.ts

+2
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export async function readOptions(
7878
funding: values.funding,
7979
offline: values.offline,
8080
owner: values.owner,
81+
preserveGeneratedFrom:
82+
values["preserve-generated-from"] ?? values.owner === "JoshuaKGoldberg",
8183
repository: values.repository,
8284
skipAllContributorsApi:
8385
values["skip-all-contributors-api"] ?? values.offline,

Diff for: src/shared/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface Options {
7070
mode: Mode;
7171
offline?: boolean;
7272
owner: string;
73+
preserveGeneratedFrom?: boolean;
7374
repository: string;
7475
skipAllContributorsApi?: boolean;
7576
skipGitHubApi?: boolean;

Diff for: src/steps/updateReadme.test.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,29 @@ describe("updateReadme", () => {
3333
"
3434
<!-- You can remove this notice if you don't want it 🙂 no worries! -->
3535
36-
> 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).
36+
> 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).
3737
",
3838
],
3939
]
4040
`);
4141
});
4242

43-
it("doesn't adds a notice when the file contains it already", async () => {
44-
mockReadFileSafe.mockResolvedValue(
45-
"<!-- You can remove this notice if you don't want it 🙂 no worries! -->",
46-
);
43+
it("doesn't add a notice when the file contains it already", async () => {
44+
mockReadFileSafe.mockResolvedValue(`
45+
<!-- You can remove this notice if you don't want it 🙂 no worries! -->
46+
47+
> 💙 This package was templated using [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).
48+
`);
49+
50+
await updateReadme();
51+
52+
expect(mockAppendFile.mock.calls).toMatchInlineSnapshot("[]");
53+
});
54+
55+
it("doesn't add a notice when the file contains an older version of it already", async () => {
56+
mockReadFileSafe.mockResolvedValue(`
57+
💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).
58+
`);
4759

4860
await updateReadme();
4961

Diff for: src/steps/updateReadme.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,21 @@ import { EOL } from "node:os";
33

44
import { readFileSafe } from "../shared/readFileSafe.js";
55

6-
const detectionLine = `<!-- You can remove this notice if you don't want it 🙂 no worries! -->`;
7-
86
export const endOfReadmeNotice = [
97
``,
10-
detectionLine,
8+
`<!-- You can remove this notice if you don't want it 🙂 no worries! -->`,
119
``,
12-
`> 💙 This package is based on [@JoshuaKGoldberg](https://github.com/JoshuaKGoldberg)'s [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`,
10+
`> 💙 This package was templated with [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app).`,
1311
``,
1412
].join(EOL);
1513

14+
export const endOfReadmeMatcher =
15+
/💙.+(?:based|built|templated).+(?:from|using|on|with).+create-typescript-app/;
16+
1617
export async function updateReadme() {
1718
const contents = await readFileSafe("./README.md", "");
1819

19-
if (!contents.includes(detectionLine)) {
20+
if (!endOfReadmeMatcher.test(contents)) {
2021
await fs.appendFile("./README.md", endOfReadmeNotice);
2122
}
2223
}

0 commit comments

Comments
 (0)