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