github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/edit/edit_test.go (about) 1 package edit 2 3 import ( 4 "bytes" 5 "fmt" 6 "net/http" 7 "os" 8 "path/filepath" 9 "testing" 10 11 "github.com/ungtb10d/cli/v2/api" 12 "github.com/ungtb10d/cli/v2/internal/ghrepo" 13 shared "github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared" 14 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 15 "github.com/ungtb10d/cli/v2/pkg/httpmock" 16 "github.com/ungtb10d/cli/v2/pkg/iostreams" 17 "github.com/google/shlex" 18 "github.com/stretchr/testify/assert" 19 "github.com/stretchr/testify/require" 20 ) 21 22 func TestNewCmdEdit(t *testing.T) { 23 tmpFile := filepath.Join(t.TempDir(), "my-body.md") 24 err := os.WriteFile(tmpFile, []byte("a body from file"), 0600) 25 require.NoError(t, err) 26 27 tests := []struct { 28 name string 29 input string 30 stdin string 31 output EditOptions 32 wantsErr bool 33 }{ 34 { 35 name: "no argument", 36 input: "", 37 output: EditOptions{ 38 SelectorArg: "", 39 Interactive: true, 40 }, 41 wantsErr: false, 42 }, 43 { 44 name: "two arguments", 45 input: "1 2", 46 output: EditOptions{}, 47 wantsErr: true, 48 }, 49 { 50 name: "pull request number argument", 51 input: "23", 52 output: EditOptions{ 53 SelectorArg: "23", 54 Interactive: true, 55 }, 56 wantsErr: false, 57 }, 58 { 59 name: "title flag", 60 input: "23 --title test", 61 output: EditOptions{ 62 SelectorArg: "23", 63 Editable: shared.Editable{ 64 Title: shared.EditableString{ 65 Value: "test", 66 Edited: true, 67 }, 68 }, 69 }, 70 wantsErr: false, 71 }, 72 { 73 name: "body flag", 74 input: "23 --body test", 75 output: EditOptions{ 76 SelectorArg: "23", 77 Editable: shared.Editable{ 78 Body: shared.EditableString{ 79 Value: "test", 80 Edited: true, 81 }, 82 }, 83 }, 84 wantsErr: false, 85 }, 86 { 87 name: "body from stdin", 88 input: "23 --body-file -", 89 stdin: "this is on standard input", 90 output: EditOptions{ 91 SelectorArg: "23", 92 Editable: shared.Editable{ 93 Body: shared.EditableString{ 94 Value: "this is on standard input", 95 Edited: true, 96 }, 97 }, 98 }, 99 wantsErr: false, 100 }, 101 { 102 name: "body from file", 103 input: fmt.Sprintf("23 --body-file '%s'", tmpFile), 104 output: EditOptions{ 105 SelectorArg: "23", 106 Editable: shared.Editable{ 107 Body: shared.EditableString{ 108 Value: "a body from file", 109 Edited: true, 110 }, 111 }, 112 }, 113 wantsErr: false, 114 }, 115 { 116 name: "base flag", 117 input: "23 --base base-branch-name", 118 output: EditOptions{ 119 SelectorArg: "23", 120 Editable: shared.Editable{ 121 Base: shared.EditableString{ 122 Value: "base-branch-name", 123 Edited: true, 124 }, 125 }, 126 }, 127 wantsErr: false, 128 }, 129 { 130 name: "add-reviewer flag", 131 input: "23 --add-reviewer monalisa,owner/core", 132 output: EditOptions{ 133 SelectorArg: "23", 134 Editable: shared.Editable{ 135 Reviewers: shared.EditableSlice{ 136 Add: []string{"monalisa", "owner/core"}, 137 Edited: true, 138 }, 139 }, 140 }, 141 wantsErr: false, 142 }, 143 { 144 name: "remove-reviewer flag", 145 input: "23 --remove-reviewer monalisa,owner/core", 146 output: EditOptions{ 147 SelectorArg: "23", 148 Editable: shared.Editable{ 149 Reviewers: shared.EditableSlice{ 150 Remove: []string{"monalisa", "owner/core"}, 151 Edited: true, 152 }, 153 }, 154 }, 155 wantsErr: false, 156 }, 157 { 158 name: "add-assignee flag", 159 input: "23 --add-assignee monalisa,hubot", 160 output: EditOptions{ 161 SelectorArg: "23", 162 Editable: shared.Editable{ 163 Assignees: shared.EditableSlice{ 164 Add: []string{"monalisa", "hubot"}, 165 Edited: true, 166 }, 167 }, 168 }, 169 wantsErr: false, 170 }, 171 { 172 name: "remove-assignee flag", 173 input: "23 --remove-assignee monalisa,hubot", 174 output: EditOptions{ 175 SelectorArg: "23", 176 Editable: shared.Editable{ 177 Assignees: shared.EditableSlice{ 178 Remove: []string{"monalisa", "hubot"}, 179 Edited: true, 180 }, 181 }, 182 }, 183 wantsErr: false, 184 }, 185 { 186 name: "add-label flag", 187 input: "23 --add-label feature,TODO,bug", 188 output: EditOptions{ 189 SelectorArg: "23", 190 Editable: shared.Editable{ 191 Labels: shared.EditableSlice{ 192 Add: []string{"feature", "TODO", "bug"}, 193 Edited: true, 194 }, 195 }, 196 }, 197 wantsErr: false, 198 }, 199 { 200 name: "remove-label flag", 201 input: "23 --remove-label feature,TODO,bug", 202 output: EditOptions{ 203 SelectorArg: "23", 204 Editable: shared.Editable{ 205 Labels: shared.EditableSlice{ 206 Remove: []string{"feature", "TODO", "bug"}, 207 Edited: true, 208 }, 209 }, 210 }, 211 wantsErr: false, 212 }, 213 { 214 name: "add-project flag", 215 input: "23 --add-project Cleanup,Roadmap", 216 output: EditOptions{ 217 SelectorArg: "23", 218 Editable: shared.Editable{ 219 Projects: shared.EditableSlice{ 220 Add: []string{"Cleanup", "Roadmap"}, 221 Edited: true, 222 }, 223 }, 224 }, 225 wantsErr: false, 226 }, 227 { 228 name: "remove-project flag", 229 input: "23 --remove-project Cleanup,Roadmap", 230 output: EditOptions{ 231 SelectorArg: "23", 232 Editable: shared.Editable{ 233 Projects: shared.EditableSlice{ 234 Remove: []string{"Cleanup", "Roadmap"}, 235 Edited: true, 236 }, 237 }, 238 }, 239 wantsErr: false, 240 }, 241 { 242 name: "milestone flag", 243 input: "23 --milestone GA", 244 output: EditOptions{ 245 SelectorArg: "23", 246 Editable: shared.Editable{ 247 Milestone: shared.EditableString{ 248 Value: "GA", 249 Edited: true, 250 }, 251 }, 252 }, 253 wantsErr: false, 254 }, 255 } 256 for _, tt := range tests { 257 t.Run(tt.name, func(t *testing.T) { 258 ios, stdin, _, _ := iostreams.Test() 259 ios.SetStdoutTTY(true) 260 ios.SetStdinTTY(true) 261 ios.SetStderrTTY(true) 262 263 if tt.stdin != "" { 264 _, _ = stdin.WriteString(tt.stdin) 265 } 266 267 f := &cmdutil.Factory{ 268 IOStreams: ios, 269 } 270 271 argv, err := shlex.Split(tt.input) 272 assert.NoError(t, err) 273 274 var gotOpts *EditOptions 275 cmd := NewCmdEdit(f, func(opts *EditOptions) error { 276 gotOpts = opts 277 return nil 278 }) 279 cmd.Flags().BoolP("help", "x", false, "") 280 281 cmd.SetArgs(argv) 282 cmd.SetIn(&bytes.Buffer{}) 283 cmd.SetOut(&bytes.Buffer{}) 284 cmd.SetErr(&bytes.Buffer{}) 285 286 _, err = cmd.ExecuteC() 287 if tt.wantsErr { 288 assert.Error(t, err) 289 return 290 } 291 292 assert.NoError(t, err) 293 assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) 294 assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) 295 assert.Equal(t, tt.output.Editable, gotOpts.Editable) 296 }) 297 } 298 } 299 300 func Test_editRun(t *testing.T) { 301 tests := []struct { 302 name string 303 input *EditOptions 304 httpStubs func(*testing.T, *httpmock.Registry) 305 stdout string 306 stderr string 307 }{ 308 { 309 name: "non-interactive", 310 input: &EditOptions{ 311 SelectorArg: "123", 312 Finder: shared.NewMockFinder("123", &api.PullRequest{ 313 URL: "https://github.com/OWNER/REPO/pull/123", 314 }, ghrepo.New("OWNER", "REPO")), 315 Interactive: false, 316 Editable: shared.Editable{ 317 Title: shared.EditableString{ 318 Value: "new title", 319 Edited: true, 320 }, 321 Body: shared.EditableString{ 322 Value: "new body", 323 Edited: true, 324 }, 325 Base: shared.EditableString{ 326 Value: "base-branch-name", 327 Edited: true, 328 }, 329 Reviewers: shared.EditableSlice{ 330 Add: []string{"OWNER/core", "OWNER/external", "monalisa", "hubot"}, 331 Remove: []string{"dependabot"}, 332 Edited: true, 333 }, 334 Assignees: shared.EditableSlice{ 335 Add: []string{"monalisa", "hubot"}, 336 Remove: []string{"octocat"}, 337 Edited: true, 338 }, 339 Labels: shared.EditableSlice{ 340 Add: []string{"feature", "TODO", "bug"}, 341 Remove: []string{"docs"}, 342 Edited: true, 343 }, 344 Projects: shared.EditableSlice{ 345 Add: []string{"Cleanup", "Roadmap"}, 346 Remove: []string{"Features"}, 347 Edited: true, 348 }, 349 Milestone: shared.EditableString{ 350 Value: "GA", 351 Edited: true, 352 }, 353 }, 354 Fetcher: testFetcher{}, 355 }, 356 httpStubs: func(t *testing.T, reg *httpmock.Registry) { 357 mockRepoMetadata(t, reg, false) 358 mockPullRequestUpdate(t, reg) 359 mockPullRequestReviewersUpdate(t, reg) 360 mockPullRequestUpdateLabels(t, reg) 361 }, 362 stdout: "https://github.com/OWNER/REPO/pull/123\n", 363 }, 364 { 365 name: "non-interactive skip reviewers", 366 input: &EditOptions{ 367 SelectorArg: "123", 368 Finder: shared.NewMockFinder("123", &api.PullRequest{ 369 URL: "https://github.com/OWNER/REPO/pull/123", 370 }, ghrepo.New("OWNER", "REPO")), 371 Interactive: false, 372 Editable: shared.Editable{ 373 Title: shared.EditableString{ 374 Value: "new title", 375 Edited: true, 376 }, 377 Body: shared.EditableString{ 378 Value: "new body", 379 Edited: true, 380 }, 381 Base: shared.EditableString{ 382 Value: "base-branch-name", 383 Edited: true, 384 }, 385 Assignees: shared.EditableSlice{ 386 Add: []string{"monalisa", "hubot"}, 387 Remove: []string{"octocat"}, 388 Edited: true, 389 }, 390 Labels: shared.EditableSlice{ 391 Add: []string{"feature", "TODO", "bug"}, 392 Remove: []string{"docs"}, 393 Edited: true, 394 }, 395 Projects: shared.EditableSlice{ 396 Value: []string{"Cleanup", "Roadmap"}, 397 Remove: []string{"Features"}, 398 Edited: true, 399 }, 400 Milestone: shared.EditableString{ 401 Value: "GA", 402 Edited: true, 403 }, 404 }, 405 Fetcher: testFetcher{}, 406 }, 407 httpStubs: func(t *testing.T, reg *httpmock.Registry) { 408 mockRepoMetadata(t, reg, true) 409 mockPullRequestUpdate(t, reg) 410 mockPullRequestUpdateLabels(t, reg) 411 }, 412 stdout: "https://github.com/OWNER/REPO/pull/123\n", 413 }, 414 { 415 name: "interactive", 416 input: &EditOptions{ 417 SelectorArg: "123", 418 Finder: shared.NewMockFinder("123", &api.PullRequest{ 419 URL: "https://github.com/OWNER/REPO/pull/123", 420 }, ghrepo.New("OWNER", "REPO")), 421 Interactive: true, 422 Surveyor: testSurveyor{}, 423 Fetcher: testFetcher{}, 424 EditorRetriever: testEditorRetriever{}, 425 }, 426 httpStubs: func(t *testing.T, reg *httpmock.Registry) { 427 mockRepoMetadata(t, reg, false) 428 mockPullRequestUpdate(t, reg) 429 mockPullRequestReviewersUpdate(t, reg) 430 }, 431 stdout: "https://github.com/OWNER/REPO/pull/123\n", 432 }, 433 { 434 name: "interactive skip reviewers", 435 input: &EditOptions{ 436 SelectorArg: "123", 437 Finder: shared.NewMockFinder("123", &api.PullRequest{ 438 URL: "https://github.com/OWNER/REPO/pull/123", 439 }, ghrepo.New("OWNER", "REPO")), 440 Interactive: true, 441 Surveyor: testSurveyor{skipReviewers: true}, 442 Fetcher: testFetcher{}, 443 EditorRetriever: testEditorRetriever{}, 444 }, 445 httpStubs: func(t *testing.T, reg *httpmock.Registry) { 446 mockRepoMetadata(t, reg, true) 447 mockPullRequestUpdate(t, reg) 448 }, 449 stdout: "https://github.com/OWNER/REPO/pull/123\n", 450 }, 451 } 452 for _, tt := range tests { 453 ios, _, stdout, stderr := iostreams.Test() 454 ios.SetStdoutTTY(true) 455 ios.SetStdinTTY(true) 456 ios.SetStderrTTY(true) 457 458 reg := &httpmock.Registry{} 459 defer reg.Verify(t) 460 tt.httpStubs(t, reg) 461 462 httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } 463 464 tt.input.IO = ios 465 tt.input.HttpClient = httpClient 466 467 t.Run(tt.name, func(t *testing.T) { 468 err := editRun(tt.input) 469 assert.NoError(t, err) 470 assert.Equal(t, tt.stdout, stdout.String()) 471 assert.Equal(t, tt.stderr, stderr.String()) 472 }) 473 } 474 } 475 476 func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) { 477 reg.Register( 478 httpmock.GraphQL(`query RepositoryAssignableUsers\b`), 479 httpmock.StringResponse(` 480 { "data": { "repository": { "assignableUsers": { 481 "nodes": [ 482 { "login": "hubot", "id": "HUBOTID" }, 483 { "login": "MonaLisa", "id": "MONAID" } 484 ], 485 "pageInfo": { "hasNextPage": false } 486 } } } } 487 `)) 488 reg.Register( 489 httpmock.GraphQL(`query RepositoryLabelList\b`), 490 httpmock.StringResponse(` 491 { "data": { "repository": { "labels": { 492 "nodes": [ 493 { "name": "feature", "id": "FEATUREID" }, 494 { "name": "TODO", "id": "TODOID" }, 495 { "name": "bug", "id": "BUGID" }, 496 { "name": "docs", "id": "DOCSID" } 497 ], 498 "pageInfo": { "hasNextPage": false } 499 } } } } 500 `)) 501 reg.Register( 502 httpmock.GraphQL(`query RepositoryMilestoneList\b`), 503 httpmock.StringResponse(` 504 { "data": { "repository": { "milestones": { 505 "nodes": [ 506 { "title": "GA", "id": "GAID" }, 507 { "title": "Big One.oh", "id": "BIGONEID" } 508 ], 509 "pageInfo": { "hasNextPage": false } 510 } } } } 511 `)) 512 reg.Register( 513 httpmock.GraphQL(`query RepositoryProjectList\b`), 514 httpmock.StringResponse(` 515 { "data": { "repository": { "projects": { 516 "nodes": [ 517 { "name": "Cleanup", "id": "CLEANUPID" }, 518 { "name": "Roadmap", "id": "ROADMAPID" } 519 ], 520 "pageInfo": { "hasNextPage": false } 521 } } } } 522 `)) 523 reg.Register( 524 httpmock.GraphQL(`query OrganizationProjectList\b`), 525 httpmock.StringResponse(` 526 { "data": { "organization": { "projects": { 527 "nodes": [ 528 { "name": "Triage", "id": "TRIAGEID" } 529 ], 530 "pageInfo": { "hasNextPage": false } 531 } } } } 532 `)) 533 if !skipReviewers { 534 reg.Register( 535 httpmock.GraphQL(`query OrganizationTeamList\b`), 536 httpmock.StringResponse(` 537 { "data": { "organization": { "teams": { 538 "nodes": [ 539 { "slug": "external", "id": "EXTERNALID" }, 540 { "slug": "core", "id": "COREID" } 541 ], 542 "pageInfo": { "hasNextPage": false } 543 } } } } 544 `)) 545 } 546 } 547 548 func mockPullRequestUpdate(t *testing.T, reg *httpmock.Registry) { 549 reg.Register( 550 httpmock.GraphQL(`mutation PullRequestUpdate\b`), 551 httpmock.StringResponse(`{}`)) 552 } 553 554 func mockPullRequestReviewersUpdate(t *testing.T, reg *httpmock.Registry) { 555 reg.Register( 556 httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`), 557 httpmock.StringResponse(`{}`)) 558 } 559 560 func mockPullRequestUpdateLabels(t *testing.T, reg *httpmock.Registry) { 561 reg.Register( 562 httpmock.GraphQL(`mutation LabelAdd\b`), 563 httpmock.GraphQLMutation(` 564 { "data": { "addLabelsToLabelable": { "__typename": "" } } }`, 565 func(inputs map[string]interface{}) {}), 566 ) 567 reg.Register( 568 httpmock.GraphQL(`mutation LabelRemove\b`), 569 httpmock.GraphQLMutation(` 570 { "data": { "removeLabelsFromLabelable": { "__typename": "" } } }`, 571 func(inputs map[string]interface{}) {}), 572 ) 573 } 574 575 type testFetcher struct{} 576 type testSurveyor struct { 577 skipReviewers bool 578 } 579 type testEditorRetriever struct{} 580 581 func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error { 582 return shared.FetchOptions(client, repo, opts) 583 } 584 585 func (s testSurveyor) FieldsToEdit(e *shared.Editable) error { 586 e.Title.Edited = true 587 e.Body.Edited = true 588 if !s.skipReviewers { 589 e.Reviewers.Edited = true 590 } 591 e.Assignees.Edited = true 592 e.Labels.Edited = true 593 e.Projects.Edited = true 594 e.Milestone.Edited = true 595 return nil 596 } 597 598 func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { 599 e.Title.Value = "new title" 600 e.Body.Value = "new body" 601 if !s.skipReviewers { 602 e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} 603 } 604 e.Assignees.Value = []string{"monalisa", "hubot"} 605 e.Labels.Value = []string{"feature", "TODO", "bug"} 606 e.Projects.Value = []string{"Cleanup", "Roadmap"} 607 e.Milestone.Value = "GA" 608 return nil 609 } 610 611 func (t testEditorRetriever) Retrieve() (string, error) { 612 return "vim", nil 613 }