1package create
2
3import (
4	"bytes"
5	"net/http"
6	"testing"
7
8	"github.com/cli/cli/v2/internal/config"
9	"github.com/cli/cli/v2/internal/run"
10	"github.com/cli/cli/v2/pkg/cmdutil"
11	"github.com/cli/cli/v2/pkg/httpmock"
12	"github.com/cli/cli/v2/pkg/iostreams"
13	"github.com/cli/cli/v2/pkg/prompt"
14	"github.com/google/shlex"
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17)
18
19func TestNewCmdCreate(t *testing.T) {
20	tests := []struct {
21		name      string
22		tty       bool
23		cli       string
24		wantsErr  bool
25		errMsg    string
26		wantsOpts CreateOptions
27	}{
28		{
29			name:      "no args tty",
30			tty:       true,
31			cli:       "",
32			wantsOpts: CreateOptions{Interactive: true},
33		},
34		{
35			name:     "no args no-tty",
36			tty:      false,
37			cli:      "",
38			wantsErr: true,
39			errMsg:   "at least one argument required in non-interactive mode",
40		},
41		{
42			name: "new repo from remote",
43			cli:  "NEWREPO --public --clone",
44			wantsOpts: CreateOptions{
45				Name:   "NEWREPO",
46				Public: true,
47				Clone:  true},
48		},
49		{
50			name:     "no visibility",
51			tty:      true,
52			cli:      "NEWREPO",
53			wantsErr: true,
54			errMsg:   "`--public`, `--private`, or `--internal` required when not running interactively",
55		},
56		{
57			name:     "multiple visibility",
58			tty:      true,
59			cli:      "NEWREPO --public --private",
60			wantsErr: true,
61			errMsg:   "expected exactly one of `--public`, `--private`, or `--internal`",
62		},
63		{
64			name: "new remote from local",
65			cli:  "--source=/path/to/repo --private",
66			wantsOpts: CreateOptions{
67				Private: true,
68				Source:  "/path/to/repo"},
69		},
70		{
71			name: "new remote from local with remote",
72			cli:  "--source=/path/to/repo --public --remote upstream",
73			wantsOpts: CreateOptions{
74				Public: true,
75				Source: "/path/to/repo",
76				Remote: "upstream",
77			},
78		},
79		{
80			name: "new remote from local with push",
81			cli:  "--source=/path/to/repo --push --public",
82			wantsOpts: CreateOptions{
83				Public: true,
84				Source: "/path/to/repo",
85				Push:   true,
86			},
87		},
88		{
89			name: "new remote from local without visibility",
90			cli:  "--source=/path/to/repo --push",
91			wantsOpts: CreateOptions{
92				Source: "/path/to/repo",
93				Push:   true,
94			},
95			wantsErr: true,
96			errMsg:   "`--public`, `--private`, or `--internal` required when not running interactively",
97		},
98		{
99			name:     "source with template",
100			cli:      "--source=/path/to/repo --private --template mytemplate",
101			wantsErr: true,
102			errMsg:   "the `--source` option is not supported with `--clone`, `--template`, `--license`, or `--gitignore`",
103		},
104	}
105
106	for _, tt := range tests {
107		t.Run(tt.name, func(t *testing.T) {
108			io, _, _, _ := iostreams.Test()
109			io.SetStdinTTY(tt.tty)
110			io.SetStdoutTTY(tt.tty)
111
112			f := &cmdutil.Factory{
113				IOStreams: io,
114			}
115
116			var opts *CreateOptions
117			cmd := NewCmdCreate(f, func(o *CreateOptions) error {
118				opts = o
119				return nil
120			})
121
122			// TODO STUPID HACK
123			// cobra aggressively adds help to all commands. since we're not running through the root command
124			// (which manages help when running for real) and since create has a '-h' flag (for homepage),
125			// cobra blows up when it tried to add a help flag and -h is already in use. This hack adds a
126			// dummy help flag with a random shorthand to get around this.
127			cmd.Flags().BoolP("help", "x", false, "")
128
129			args, err := shlex.Split(tt.cli)
130			require.NoError(t, err)
131			cmd.SetArgs(args)
132			cmd.SetIn(&bytes.Buffer{})
133			cmd.SetOut(&bytes.Buffer{})
134			cmd.SetErr(&bytes.Buffer{})
135
136			_, err = cmd.ExecuteC()
137			if tt.wantsErr {
138				assert.Error(t, err)
139				assert.Equal(t, tt.errMsg, err.Error())
140				return
141			} else {
142				require.NoError(t, err)
143			}
144
145			assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive)
146			assert.Equal(t, tt.wantsOpts.Source, opts.Source)
147			assert.Equal(t, tt.wantsOpts.Name, opts.Name)
148			assert.Equal(t, tt.wantsOpts.Public, opts.Public)
149			assert.Equal(t, tt.wantsOpts.Internal, opts.Internal)
150			assert.Equal(t, tt.wantsOpts.Private, opts.Private)
151			assert.Equal(t, tt.wantsOpts.Clone, opts.Clone)
152		})
153	}
154}
155
156func Test_createRun(t *testing.T) {
157	tests := []struct {
158		name       string
159		tty        bool
160		opts       *CreateOptions
161		httpStubs  func(*httpmock.Registry)
162		askStubs   func(*prompt.AskStubber)
163		execStubs  func(*run.CommandStubber)
164		wantStdout string
165		wantErr    bool
166		errMsg     string
167	}{
168		{
169			name:       "interactive create from scratch with gitignore and license",
170			opts:       &CreateOptions{Interactive: true},
171			tty:        true,
172			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n",
173			askStubs: func(as *prompt.AskStubber) {
174				as.StubOne("Create a new repository on GitHub from scratch")
175				as.Stub([]*prompt.QuestionStub{
176					{Name: "repoName", Value: "REPO"},
177					{Name: "repoDescription", Value: "my new repo"},
178					{Name: "repoVisibility", Value: "PRIVATE"},
179				})
180				as.Stub([]*prompt.QuestionStub{
181					{Name: "addGitIgnore", Value: true}})
182				as.Stub([]*prompt.QuestionStub{
183					{Name: "chooseGitIgnore", Value: "Go"}})
184				as.Stub([]*prompt.QuestionStub{
185					{Name: "addLicense", Value: true}})
186				as.Stub([]*prompt.QuestionStub{
187					{Name: "chooseLicense", Value: "GNU Lesser General Public License v3.0"}})
188				as.Stub([]*prompt.QuestionStub{
189					{Name: "confirmSubmit", Value: true}})
190				as.StubOne(true) //clone locally?
191			},
192			httpStubs: func(reg *httpmock.Registry) {
193				reg.Register(
194					httpmock.REST("GET", "gitignore/templates"),
195					httpmock.StringResponse(`["Actionscript","Android","AppceleratorTitanium","Autotools","Bancha","C","C++","Go"]`))
196				reg.Register(
197					httpmock.REST("GET", "licenses"),
198					httpmock.StringResponse(`[{"key": "mit","name": "MIT License"},{"key": "lgpl-3.0","name": "GNU Lesser General Public License v3.0"}]`))
199				reg.Register(
200					httpmock.REST("POST", "user/repos"),
201					httpmock.StringResponse(`{"name":"REPO", "owner":{"login": "OWNER"}, "html_url":"https://github.com/OWNER/REPO"}`))
202
203			},
204			execStubs: func(cs *run.CommandStubber) {
205				cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
206			},
207		},
208		{
209			name: "interactive create from scratch but cancel before submit",
210			opts: &CreateOptions{Interactive: true},
211			tty:  true,
212			askStubs: func(as *prompt.AskStubber) {
213				as.StubOne("Create a new repository on GitHub from scratch")
214				as.Stub([]*prompt.QuestionStub{
215					{Name: "repoName", Value: "REPO"},
216					{Name: "repoDescription", Value: "my new repo"},
217					{Name: "repoVisibility", Value: "PRIVATE"},
218				})
219				as.Stub([]*prompt.QuestionStub{
220					{Name: "addGitIgnore", Value: false}})
221				as.Stub([]*prompt.QuestionStub{
222					{Name: "addLicense", Value: false}})
223				as.Stub([]*prompt.QuestionStub{
224					{Name: "confirmSubmit", Value: false}})
225			},
226			wantStdout: "",
227			wantErr:    true,
228			errMsg:     "CancelError",
229		},
230		{
231			name: "interactive with existing repository public",
232			opts: &CreateOptions{Interactive: true},
233			tty:  true,
234			askStubs: func(as *prompt.AskStubber) {
235				as.StubOne("Push an existing local repository to GitHub")
236				as.StubOne(".")
237				as.Stub([]*prompt.QuestionStub{
238					{Name: "repoName", Value: "REPO"},
239					{Name: "repoDescription", Value: "my new repo"},
240					{Name: "repoVisibility", Value: "PRIVATE"},
241				})
242				as.StubOne(false)
243			},
244			httpStubs: func(reg *httpmock.Registry) {
245				reg.Register(
246					httpmock.GraphQL(`mutation RepositoryCreate\b`),
247					httpmock.StringResponse(`
248					{
249						"data": {
250							"createRepository": {
251								"repository": {
252									"id": "REPOID",
253									"name": "REPO",
254									"owner": {"login":"OWNER"},
255									"url": "https://github.com/OWNER/REPO"
256								}
257							}
258						}
259					}`))
260			},
261			execStubs: func(cs *run.CommandStubber) {
262				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
263				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
264			},
265			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n",
266		},
267		{
268			name: "interactive with existing repository public add remote",
269			opts: &CreateOptions{Interactive: true},
270			tty:  true,
271			askStubs: func(as *prompt.AskStubber) {
272				as.StubOne("Push an existing local repository to GitHub")
273				as.StubOne(".")
274				as.Stub([]*prompt.QuestionStub{
275					{Name: "repoName", Value: "REPO"},
276					{Name: "repoDescription", Value: "my new repo"},
277					{Name: "repoVisibility", Value: "PRIVATE"},
278				})
279				as.StubOne(true)     //ask for adding a remote
280				as.StubOne("origin") //ask for remote name
281				as.StubOne(false)    //ask to push to remote
282			},
283			httpStubs: func(reg *httpmock.Registry) {
284				reg.Register(
285					httpmock.GraphQL(`mutation RepositoryCreate\b`),
286					httpmock.StringResponse(`
287					{
288						"data": {
289							"createRepository": {
290								"repository": {
291									"id": "REPOID",
292									"name": "REPO",
293									"owner": {"login":"OWNER"},
294									"url": "https://github.com/OWNER/REPO"
295								}
296							}
297						}
298					}`))
299			},
300			execStubs: func(cs *run.CommandStubber) {
301				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
302				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
303				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
304			},
305			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n",
306		},
307		{
308			name: "interactive with existing repository public, add remote, and push",
309			opts: &CreateOptions{Interactive: true},
310			tty:  true,
311			askStubs: func(as *prompt.AskStubber) {
312				as.StubOne("Push an existing local repository to GitHub")
313				as.StubOne(".")
314				as.Stub([]*prompt.QuestionStub{
315					{Name: "repoName", Value: "REPO"},
316					{Name: "repoDescription", Value: "my new repo"},
317					{Name: "repoVisibility", Value: "PRIVATE"},
318				})
319				as.StubOne(true)     //ask for adding a remote
320				as.StubOne("origin") //ask for remote name
321				as.StubOne(true)     //ask to push to remote
322			},
323			httpStubs: func(reg *httpmock.Registry) {
324				reg.Register(
325					httpmock.GraphQL(`mutation RepositoryCreate\b`),
326					httpmock.StringResponse(`
327					{
328						"data": {
329							"createRepository": {
330								"repository": {
331									"id": "REPOID",
332									"name": "REPO",
333									"owner": {"login":"OWNER"},
334									"url": "https://github.com/OWNER/REPO"
335								}
336							}
337						}
338					}`))
339			},
340			execStubs: func(cs *run.CommandStubber) {
341				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
342				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
343				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
344				cs.Register(`git -C . push -u origin HEAD`, 0, "")
345			},
346			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
347		},
348		{
349			name: "noninteractive create from scratch",
350			opts: &CreateOptions{
351				Interactive: false,
352				Name:        "REPO",
353				Visibility:  "PRIVATE",
354			},
355			tty: false,
356			httpStubs: func(reg *httpmock.Registry) {
357				reg.Register(
358					httpmock.GraphQL(`mutation RepositoryCreate\b`),
359					httpmock.StringResponse(`
360					{
361						"data": {
362							"createRepository": {
363								"repository": {
364									"id": "REPOID",
365									"name": "REPO",
366									"owner": {"login":"OWNER"},
367									"url": "https://github.com/OWNER/REPO"
368								}
369							}
370						}
371					}`))
372			},
373			wantStdout: "https://github.com/OWNER/REPO\n",
374		},
375		{
376			name: "noninteractive create from source",
377			opts: &CreateOptions{
378				Interactive: false,
379				Source:      ".",
380				Name:        "REPO",
381				Visibility:  "PRIVATE",
382			},
383			tty: false,
384			httpStubs: func(reg *httpmock.Registry) {
385				reg.Register(
386					httpmock.GraphQL(`mutation RepositoryCreate\b`),
387					httpmock.StringResponse(`
388					{
389						"data": {
390							"createRepository": {
391								"repository": {
392									"id": "REPOID",
393									"name": "REPO",
394									"owner": {"login":"OWNER"},
395									"url": "https://github.com/OWNER/REPO"
396								}
397							}
398						}
399					}`))
400			},
401			execStubs: func(cs *run.CommandStubber) {
402				cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
403				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
404				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
405			},
406			wantStdout: "https://github.com/OWNER/REPO\n",
407		},
408	}
409	for _, tt := range tests {
410		q, teardown := prompt.InitAskStubber()
411		defer teardown()
412		if tt.askStubs != nil {
413			tt.askStubs(q)
414		}
415
416		reg := &httpmock.Registry{}
417		if tt.httpStubs != nil {
418			tt.httpStubs(reg)
419		}
420		tt.opts.HttpClient = func() (*http.Client, error) {
421			return &http.Client{Transport: reg}, nil
422		}
423		tt.opts.Config = func() (config.Config, error) {
424			return config.NewBlankConfig(), nil
425		}
426
427		cs, restoreRun := run.Stub()
428		defer restoreRun(t)
429		if tt.execStubs != nil {
430			tt.execStubs(cs)
431		}
432
433		io, _, stdout, stderr := iostreams.Test()
434		io.SetStdinTTY(tt.tty)
435		io.SetStdoutTTY(tt.tty)
436		tt.opts.IO = io
437
438		t.Run(tt.name, func(t *testing.T) {
439			defer reg.Verify(t)
440			err := createRun(tt.opts)
441			if tt.wantErr {
442				assert.Error(t, err)
443				assert.Equal(t, tt.errMsg, err.Error())
444				return
445			}
446			assert.NoError(t, err)
447			assert.Equal(t, tt.wantStdout, stdout.String())
448			assert.Equal(t, "", stderr.String())
449		})
450	}
451}
452
453func Test_getModifiedNormalizedName(t *testing.T) {
454	// confirmed using GitHub.com/new
455	tests := []struct {
456		LocalName      string
457		NormalizedName string
458	}{
459		{
460			LocalName:      "cli",
461			NormalizedName: "cli",
462		},
463		{
464			LocalName:      "cli.git",
465			NormalizedName: "cli",
466		},
467		{
468			LocalName:      "@-#$^",
469			NormalizedName: "---",
470		},
471		{
472			LocalName:      "[cli]",
473			NormalizedName: "-cli-",
474		},
475		{
476			LocalName:      "Hello World, I'm a new repo!",
477			NormalizedName: "Hello-World-I-m-a-new-repo-",
478		},
479		{
480			LocalName:      " @E3H*(#$#_$-ZVp,n.7lGq*_eMa-(-zAZSJYg!",
481			NormalizedName: "-E3H-_--ZVp-n.7lGq-_eMa---zAZSJYg-",
482		},
483		{
484			LocalName:      "I'm a crazy .git repo name .git.git .git",
485			NormalizedName: "I-m-a-crazy-.git-repo-name-.git.git-",
486		},
487	}
488	for _, tt := range tests {
489		output := normalizeRepoName(tt.LocalName)
490		assert.Equal(t, tt.NormalizedName, output)
491	}
492}
493