cli/pkg/cmd/issue/edit/edit_test.go
2023-08-17 11:28:01 -05:00

740 lines
19 KiB
Go

package edit
import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdEdit(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "my-body.md")
err := os.WriteFile(tmpFile, []byte("a body from file"), 0600)
require.NoError(t, err)
tests := []struct {
name string
input string
stdin string
output EditOptions
wantsErr bool
}{
{
name: "no argument",
input: "",
output: EditOptions{},
wantsErr: true,
},
{
name: "issue number argument",
input: "23",
output: EditOptions{
SelectorArgs: []string{"23"},
Interactive: true,
},
wantsErr: false,
},
{
name: "title flag",
input: "23 --title test",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Title: prShared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "body flag",
input: "23 --body test",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Body: prShared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "body from stdin",
input: "23 --body-file -",
stdin: "this is on standard input",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Body: prShared.EditableString{
Value: "this is on standard input",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "body from file",
input: fmt.Sprintf("23 --body-file '%s'", tmpFile),
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Body: prShared.EditableString{
Value: "a body from file",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-assignee flag",
input: "23 --add-assignee monalisa,hubot",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-assignee flag",
input: "23 --remove-assignee monalisa,hubot",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Remove: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-label flag",
input: "23 --add-label feature,TODO,bug",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-label flag",
input: "23 --remove-label feature,TODO,bug",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Remove: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-project flag",
input: "23 --add-project Cleanup,Roadmap",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Projects: prShared.EditableProjects{
EditableSlice: prShared.EditableSlice{
Add: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
},
wantsErr: false,
},
{
name: "remove-project flag",
input: "23 --remove-project Cleanup,Roadmap",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Projects: prShared.EditableProjects{
EditableSlice: prShared.EditableSlice{
Remove: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
},
wantsErr: false,
},
{
name: "milestone flag",
input: "23 --milestone GA",
output: EditOptions{
SelectorArgs: []string{"23"},
Editable: prShared.Editable{
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add label to multiple issues",
input: "23 34 --add-label bug",
output: EditOptions{
SelectorArgs: []string{"23", "34"},
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Add: []string{"bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "interactive multiple issues",
input: "23 34",
output: EditOptions{
SelectorArgs: []string{"23", "34"},
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Add: []string{"bug"},
Edited: true,
},
},
},
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
}
f := &cmdutil.Factory{
IOStreams: ios,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *EditOptions
cmd := NewCmdEdit(f, func(opts *EditOptions) error {
gotOpts = opts
return nil
})
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs)
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
})
}
}
func Test_editRun(t *testing.T) {
tests := []struct {
name string
input *EditOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
wantErr bool
}{
{
name: "non-interactive",
input: &EditOptions{
SelectorArgs: []string{"123"},
Interactive: false,
Editable: prShared.Editable{
Title: prShared.EditableString{
Value: "new title",
Edited: true,
},
Body: prShared.EditableString{
Value: "new body",
Edited: true,
},
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: prShared.EditableProjects{
EditableSlice: prShared.EditableSlice{
Add: []string{"Cleanup", "CleanupV2"},
Remove: []string{"Roadmap", "RoadmapV2"},
Edited: true,
},
},
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
Metadata: api.RepoMetadataResult{
Labels: []api.RepoLabel{
{Name: "docs", ID: "DOCSID"},
},
},
},
FetchOptions: prShared.FetchOptions,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueGet(t, reg)
mockIssueProjectItemsGet(t, reg)
mockRepoMetadata(t, reg)
mockIssueUpdate(t, reg)
mockIssueUpdateLabels(t, reg)
mockProjectV2ItemUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issue/123\n",
},
{
name: "non-interactive multiple issues",
input: &EditOptions{
SelectorArgs: []string{"456", "123"},
Interactive: false,
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: prShared.EditableProjects{
EditableSlice: prShared.EditableSlice{
Add: []string{"Cleanup", "CleanupV2"},
Remove: []string{"Roadmap", "RoadmapV2"},
Edited: true,
},
},
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
FetchOptions: prShared.FetchOptions,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// Should only be one fetch of metadata.
mockRepoMetadata(t, reg)
// All other queries and mutations should be doubled.
mockIssueNumberGet(t, reg, 123)
mockIssueNumberGet(t, reg, 456)
mockIssueProjectItemsGet(t, reg)
mockIssueProjectItemsGet(t, reg)
mockIssueUpdate(t, reg)
mockIssueUpdate(t, reg)
mockIssueUpdateLabels(t, reg)
mockIssueUpdateLabels(t, reg)
mockProjectV2ItemUpdate(t, reg)
mockProjectV2ItemUpdate(t, reg)
},
stdout: heredoc.Doc(`
https://github.com/OWNER/REPO/issue/123
https://github.com/OWNER/REPO/issue/456
`),
},
{
name: "non-interactive multiple issues with fetch failures",
input: &EditOptions{
SelectorArgs: []string{"123", "9999"},
Interactive: false,
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: prShared.EditableProjects{
EditableSlice: prShared.EditableSlice{
Add: []string{"Cleanup", "CleanupV2"},
Remove: []string{"Roadmap", "RoadmapV2"},
Edited: true,
},
},
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
FetchOptions: prShared.FetchOptions,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueNumberGet(t, reg, 123)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "errors": [
{
"type": "NOT_FOUND",
"message": "Could not resolve to an Issue with the number of 9999."
}
] }`),
)
},
wantErr: true,
},
{
name: "non-interactive multiple issues with update failures",
input: &EditOptions{
SelectorArgs: []string{"123", "456"},
Interactive: false,
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
FetchOptions: prShared.FetchOptions,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// Should only be one fetch of metadata.
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
// All other queries should be doubled.
mockIssueNumberGet(t, reg, 123)
mockIssueNumberGet(t, reg, 456)
// Updating 123 should succeed.
reg.Register(
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
return m["id"] == "123"
}),
httpmock.GraphQLMutation(`
{ "data": { "updateIssue": { "__typename": "" } } }`,
func(inputs map[string]interface{}) {}),
)
// Updating 456 should fail.
reg.Register(
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
return m["id"] == "456"
}),
httpmock.GraphQLMutation(`
{ "errors": [ { "message": "test error" } ] }`,
func(inputs map[string]interface{}) {}),
)
},
stdout: heredoc.Doc(`
https://github.com/OWNER/REPO/issue/123
`),
stderr: `failed to update https://github.com/OWNER/REPO/issue/456:.*test error`,
wantErr: true,
},
{
name: "interactive",
input: &EditOptions{
SelectorArgs: []string{"123"},
Interactive: true,
FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
eo.Title.Edited = true
eo.Body.Edited = true
eo.Assignees.Edited = true
eo.Labels.Edited = true
eo.Projects.Edited = true
eo.Milestone.Edited = true
return nil
},
EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
eo.Title.Value = "new title"
eo.Body.Value = "new body"
eo.Assignees.Value = []string{"monalisa", "hubot"}
eo.Labels.Value = []string{"feature", "TODO", "bug"}
eo.Labels.Add = []string{"feature", "TODO", "bug"}
eo.Labels.Remove = []string{"docs"}
eo.Projects.Value = []string{"Cleanup", "CleanupV2"}
eo.Milestone.Value = "GA"
return nil
},
FetchOptions: prShared.FetchOptions,
DetermineEditor: func() (string, error) { return "vim", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueGet(t, reg)
mockIssueProjectItemsGet(t, reg)
mockRepoMetadata(t, reg)
mockIssueUpdate(t, reg)
mockIssueUpdateLabels(t, reg)
mockProjectV2ItemUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issue/123\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.httpStubs(t, reg)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
tt.input.IO = ios
tt.input.HttpClient = httpClient
tt.input.BaseRepo = baseRepo
err := editRun(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.stdout, stdout.String())
// Use regex match since mock errors and service errors will differ.
assert.Regexp(t, tt.stderr, stderr.String())
})
}
}
func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
mockIssueNumberGet(nil, reg, 123)
}
func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(fmt.Sprintf(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"id": "%[1]d",
"number": %[1]d,
"url": "https://github.com/OWNER/REPO/issue/%[1]d",
"labels": {
"nodes": [
{ "id": "DOCSID", "name": "docs" }
], "totalCount": 1
},
"projectCards": {
"nodes": [
{ "project": { "name": "Roadmap" } }
], "totalCount": 1
}
} } } }`, number)),
)
}
func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueProjectItems\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": {
"projectItems": {
"nodes": [
{ "id": "ITEMID", "project": { "title": "RoadmapV2" } }
]
}
} } } }`),
)
}
func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "feature", "id": "FEATUREID" },
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" },
{ "name": "docs", "id": "DOCSID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [
{ "title": "TriageV2", "id": "TRIAGEV2ID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [
{ "title": "MonalisaV2", "id": "MONALISAV2ID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
}
func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation IssueUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updateIssue": { "__typename": "" } } }`,
func(inputs map[string]interface{}) {}),
)
}
func mockIssueUpdateLabels(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation LabelAdd\b`),
httpmock.GraphQLMutation(`
{ "data": { "addLabelsToLabelable": { "__typename": "" } } }`,
func(inputs map[string]interface{}) {}),
)
reg.Register(
httpmock.GraphQL(`mutation LabelRemove\b`),
httpmock.GraphQLMutation(`
{ "data": { "removeLabelsFromLabelable": { "__typename": "" } } }`,
func(inputs map[string]interface{}) {}),
)
}
func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
httpmock.GraphQLMutation(`
{ "data": { "add_000": { "item": { "id": "1" } }, "delete_001": { "item": { "id": "2" } } } }`,
func(inputs map[string]interface{}) {}),
)
}