Wire up MultiSelectWithSearch for assignees in MetadataSurvey, replacing the static MultiSelect that required bulk fetching all assignable actors. This applies to both gh pr create and gh issue create interactive flows when selecting assignees via the 'Add metadata' prompt. Changes: - Add assigneeSearchFunc parameter to MetadataSurvey - Skip assignee bulk fetch when search func is available - New SearchRepoAssignableActors API function for repo-level search (create flows have no issue/PR node ID yet) - New RepoAssigneeSearchFunc in shared editable.go - Refactor actorsToSearchResult helper shared by both search functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1404 lines
40 KiB
Go
1404 lines
40 KiB
Go
package create
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
"github.com/cli/cli/v2/internal/config"
|
|
fd "github.com/cli/cli/v2/internal/featuredetection"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/prompter"
|
|
"github.com/cli/cli/v2/internal/run"
|
|
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/cli/cli/v2/test"
|
|
"github.com/google/shlex"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewCmdCreate(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
|
|
tty bool
|
|
stdin string
|
|
cli string
|
|
config string
|
|
wantsErr bool
|
|
wantsOpts CreateOptions
|
|
}{
|
|
{
|
|
name: "empty non-tty",
|
|
tty: false,
|
|
cli: "",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "only title non-tty",
|
|
tty: false,
|
|
cli: "-t mytitle",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "empty tty",
|
|
tty: true,
|
|
cli: "",
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "",
|
|
Body: "",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
Interactive: true,
|
|
},
|
|
},
|
|
{
|
|
name: "body from stdin",
|
|
tty: false,
|
|
stdin: "this is on standard input",
|
|
cli: "-t mytitle -F -",
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "mytitle",
|
|
Body: "this is on standard input",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "body from file",
|
|
tty: false,
|
|
cli: fmt.Sprintf("-t mytitle -F '%s'", tmpFile),
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "mytitle",
|
|
Body: "a body from file",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "template from name tty",
|
|
tty: true,
|
|
cli: `-t mytitle --template "bug report"`,
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "mytitle",
|
|
Body: "",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
Template: "bug report",
|
|
Interactive: true,
|
|
},
|
|
},
|
|
{
|
|
name: "template from name non-tty",
|
|
tty: false,
|
|
cli: `-t mytitle --template "bug report"`,
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "template and body",
|
|
tty: false,
|
|
cli: `-t mytitle --template "bug report" --body "issue body"`,
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "template and body file",
|
|
tty: false,
|
|
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "editor by cli",
|
|
tty: true,
|
|
cli: "--editor",
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "",
|
|
Body: "",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
EditorMode: true,
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "editor by config",
|
|
tty: true,
|
|
cli: "",
|
|
config: "prefer_editor_prompt: enabled",
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "",
|
|
Body: "",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
EditorMode: true,
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "editor and template",
|
|
tty: true,
|
|
cli: `--editor --template "bug report"`,
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "",
|
|
Body: "",
|
|
RecoverFile: "",
|
|
WebMode: false,
|
|
EditorMode: true,
|
|
Template: "bug report",
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "editor and web",
|
|
tty: true,
|
|
cli: "--editor --web",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "can use web even though editor is enabled by config",
|
|
tty: true,
|
|
cli: `--web --title mytitle --body "issue body"`,
|
|
config: "prefer_editor_prompt: enabled",
|
|
wantsErr: false,
|
|
wantsOpts: CreateOptions{
|
|
Title: "mytitle",
|
|
Body: "issue body",
|
|
RecoverFile: "",
|
|
WebMode: true,
|
|
EditorMode: false,
|
|
Interactive: false,
|
|
},
|
|
},
|
|
{
|
|
name: "editor with non-tty",
|
|
tty: false,
|
|
cli: "--editor",
|
|
wantsErr: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, stdin, stdout, stderr := iostreams.Test()
|
|
if tt.stdin != "" {
|
|
_, _ = stdin.WriteString(tt.stdin)
|
|
} else if tt.tty {
|
|
ios.SetStdinTTY(true)
|
|
ios.SetStdoutTTY(true)
|
|
}
|
|
|
|
f := &cmdutil.Factory{
|
|
IOStreams: ios,
|
|
Config: func() (gh.Config, error) {
|
|
if tt.config != "" {
|
|
return config.NewFromString(tt.config), nil
|
|
}
|
|
return config.NewBlankConfig(), nil
|
|
},
|
|
}
|
|
|
|
var opts *CreateOptions
|
|
cmd := NewCmdCreate(f, func(o *CreateOptions) error {
|
|
opts = o
|
|
return nil
|
|
})
|
|
|
|
args, err := shlex.Split(tt.cli)
|
|
require.NoError(t, err)
|
|
cmd.SetArgs(args)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
_, err = cmd.ExecuteC()
|
|
if tt.wantsErr {
|
|
assert.Error(t, err)
|
|
return
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, "", stdout.String())
|
|
assert.Equal(t, "", stderr.String())
|
|
|
|
assert.Equal(t, tt.wantsOpts.Body, opts.Body)
|
|
assert.Equal(t, tt.wantsOpts.Title, opts.Title)
|
|
assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile)
|
|
assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode)
|
|
assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive)
|
|
assert.Equal(t, tt.wantsOpts.Template, opts.Template)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_createRun(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts CreateOptions
|
|
httpStubs func(*httpmock.Registry)
|
|
promptStubs func(*prompter.PrompterMock)
|
|
wantsStdout string
|
|
wantsStderr string
|
|
wantsBrowse string
|
|
wantsErr string
|
|
}{
|
|
{
|
|
name: "no args",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "title and body",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Title: "myissue",
|
|
Body: "hello cli",
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=hello+cli&title=myissue",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "assignee",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Assignees: []string{"monalisa"},
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa&body=",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "@me",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Assignees: []string{"@me"},
|
|
},
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query UserCurrent\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": {
|
|
"viewer": { "login": "MonaLisa" }
|
|
} }`))
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa&body=",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "@copilot",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Assignees: []string{"@copilot"},
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=Copilot&body=",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "project",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Projects: []string{"cleanup"},
|
|
},
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projects": {
|
|
"nodes": [
|
|
{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": { "projects": {
|
|
"nodes": [
|
|
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/2" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/2" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query UserProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "viewer": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "Monalisa", "id": "MONALISAID", "resourcePath": "/users/MONALISA/projects/1" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }`))
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new in your browser.\n",
|
|
},
|
|
{
|
|
name: "has templates",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
},
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query IssueTemplates\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTemplates": [
|
|
{ "name": "Bug report",
|
|
"body": "Does not work :((" },
|
|
{ "name": "Submit a request",
|
|
"body": "I have a suggestion for an enhancement" }
|
|
] } } }`),
|
|
)
|
|
},
|
|
wantsBrowse: "https://github.com/OWNER/REPO/issues/new/choose",
|
|
wantsStderr: "Opening https://github.com/OWNER/REPO/issues/new/choose in your browser.\n",
|
|
},
|
|
{
|
|
name: "too long body",
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
Body: strings.Repeat("A", 9216),
|
|
},
|
|
wantsErr: "cannot open in browser: maximum URL length exceeded",
|
|
},
|
|
{
|
|
name: "editor",
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "title", inputs["title"])
|
|
assert.Equal(t, "body", inputs["body"])
|
|
}))
|
|
},
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
EditorMode: true,
|
|
TitledEditSurvey: func(string, string) (string, string, error) { return "title", "body", nil },
|
|
},
|
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
|
},
|
|
{
|
|
name: "editor and template",
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query IssueTemplates\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTemplates": [
|
|
{ "name": "Bug report",
|
|
"title": "bug: ",
|
|
"body": "Does not work :((" }
|
|
] } } }`),
|
|
)
|
|
r.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "bug: ", inputs["title"])
|
|
assert.Equal(t, "Does not work :((", inputs["body"])
|
|
}))
|
|
},
|
|
opts: CreateOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
EditorMode: true,
|
|
Template: "Bug report",
|
|
TitledEditSurvey: func(title string, body string) (string, string, error) { return title, body, nil },
|
|
},
|
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
|
},
|
|
{
|
|
name: "interactive prompts with actor assignee display names when actors available",
|
|
opts: CreateOptions{
|
|
Interactive: true,
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
Title: "test `gh issue create` actor assignees",
|
|
Body: "Actor assignees allow users and bots to be assigned to issues",
|
|
},
|
|
promptStubs: func(pm *prompter.PrompterMock) {
|
|
firstConfirmSubmission := true
|
|
pm.InputFunc = func(message, defaultValue string) (string, error) {
|
|
switch message {
|
|
default:
|
|
return "", fmt.Errorf("unexpected input prompt: %s", message)
|
|
}
|
|
}
|
|
pm.MultiSelectFunc = func(message string, defaults []string, options []string) ([]int, error) {
|
|
switch message {
|
|
case "What would you like to add?":
|
|
return prompter.IndexesFor(options, "Assignees")
|
|
default:
|
|
return nil, fmt.Errorf("unexpected multi-select prompt: %s", message)
|
|
}
|
|
}
|
|
pm.MultiSelectWithSearchFunc = func(message, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) {
|
|
switch message {
|
|
case "Assignees":
|
|
return []string{"copilot-swe-agent", "MonaLisa"}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected multi-select-with-search prompt: %s", message)
|
|
}
|
|
}
|
|
pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
|
|
switch message {
|
|
case "What's next?":
|
|
if firstConfirmSubmission {
|
|
firstConfirmSubmission = false
|
|
return prompter.IndexFor(options, "Add metadata")
|
|
}
|
|
return prompter.IndexFor(options, "Submit")
|
|
default:
|
|
return 0, fmt.Errorf("unexpected select prompt: %s", message)
|
|
}
|
|
}
|
|
},
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true,
|
|
"viewerPermission": "WRITE"
|
|
} } }
|
|
`))
|
|
r.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"id": "ISSUEID",
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
if v, ok := inputs["assigneeIds"]; ok {
|
|
t.Errorf("did not expect assigneeIds: %v", v)
|
|
}
|
|
}))
|
|
r.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "ISSUEID", inputs["assignableId"])
|
|
assert.Equal(t, []interface{}{"copilot-swe-agent", "MonaLisa"}, inputs["actorLogins"])
|
|
}))
|
|
},
|
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
|
},
|
|
{
|
|
name: "interactive prompts with user assignee logins when actors unavailable",
|
|
opts: CreateOptions{
|
|
Interactive: true,
|
|
Detector: &fd.DisabledDetectorMock{},
|
|
Title: "test `gh issue create` user assignees",
|
|
Body: "User assignees allow only users to be assigned to issues",
|
|
},
|
|
promptStubs: func(pm *prompter.PrompterMock) {
|
|
firstConfirmSubmission := true
|
|
pm.InputFunc = func(message, defaultValue string) (string, error) {
|
|
switch message {
|
|
default:
|
|
return "", fmt.Errorf("unexpected input prompt: %s", message)
|
|
}
|
|
}
|
|
pm.MultiSelectFunc = func(message string, defaults []string, options []string) ([]int, error) {
|
|
switch message {
|
|
case "What would you like to add?":
|
|
return prompter.IndexesFor(options, "Assignees")
|
|
case "Assignees":
|
|
return prompter.IndexesFor(options, "hubot", "MonaLisa (Mona Display Name)")
|
|
default:
|
|
return nil, fmt.Errorf("unexpected multi-select prompt: %s", message)
|
|
}
|
|
}
|
|
pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
|
|
switch message {
|
|
case "What's next?":
|
|
if firstConfirmSubmission {
|
|
firstConfirmSubmission = false
|
|
return prompter.IndexFor(options, "Add metadata")
|
|
}
|
|
return prompter.IndexFor(options, "Submit")
|
|
default:
|
|
return 0, fmt.Errorf("unexpected select prompt: %s", message)
|
|
}
|
|
}
|
|
},
|
|
httpStubs: func(r *httpmock.Registry) {
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true,
|
|
"viewerPermission": "WRITE"
|
|
} } }
|
|
`))
|
|
r.Register(
|
|
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "assignableUsers": {
|
|
"nodes": [
|
|
{ "login": "hubot", "id": "HUBOTID", "name": "" },
|
|
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
r.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["assigneeIds"])
|
|
}))
|
|
},
|
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
httpReg := &httpmock.Registry{}
|
|
defer httpReg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(httpReg)
|
|
}
|
|
|
|
ios, _, stdout, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
opts := &tt.opts
|
|
opts.IO = ios
|
|
opts.HttpClient = func() (*http.Client, error) {
|
|
return &http.Client{Transport: httpReg}, nil
|
|
}
|
|
opts.BaseRepo = func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
}
|
|
browser := &browser.Stub{}
|
|
opts.Browser = browser
|
|
|
|
prompterMock := &prompter.PrompterMock{}
|
|
opts.Prompter = prompterMock
|
|
if tt.promptStubs != nil {
|
|
tt.promptStubs(prompterMock)
|
|
}
|
|
|
|
err := createRun(opts)
|
|
if tt.wantsErr == "" {
|
|
require.NoError(t, err)
|
|
} else {
|
|
assert.EqualError(t, err, tt.wantsErr)
|
|
return
|
|
}
|
|
|
|
assert.Equal(t, tt.wantsStdout, stdout.String())
|
|
assert.Equal(t, tt.wantsStderr, stderr.String())
|
|
browser.Verify(t, tt.wantsBrowse)
|
|
})
|
|
}
|
|
}
|
|
|
|
/*** LEGACY TESTS ***/
|
|
|
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string, pm *prompter.PrompterMock) (*test.CmdOut, error) {
|
|
return runCommandWithRootDirOverridden(rt, isTTY, cli, "", pm)
|
|
}
|
|
|
|
func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli string, rootDir string, pm *prompter.PrompterMock) (*test.CmdOut, error) {
|
|
ios, _, stdout, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(isTTY)
|
|
ios.SetStdinTTY(isTTY)
|
|
ios.SetStderrTTY(isTTY)
|
|
|
|
browser := &browser.Stub{}
|
|
factory := &cmdutil.Factory{
|
|
IOStreams: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return &http.Client{Transport: rt}, nil
|
|
},
|
|
Config: func() (gh.Config, error) {
|
|
return config.NewBlankConfig(), nil
|
|
},
|
|
BaseRepo: func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
},
|
|
Browser: browser,
|
|
Prompter: pm,
|
|
}
|
|
|
|
cmd := NewCmdCreate(factory, func(opts *CreateOptions) error {
|
|
opts.RootDirOverride = rootDir
|
|
opts.Detector = &fd.EnabledDetectorMock{}
|
|
return createRun(opts)
|
|
})
|
|
|
|
argv, err := shlex.Split(cli)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cmd.SetArgs(argv)
|
|
|
|
cmd.SetIn(&bytes.Buffer{})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
|
|
_, err = cmd.ExecuteC()
|
|
return &test.CmdOut{
|
|
OutBuf: stdout,
|
|
ErrBuf: stderr,
|
|
BrowsedURL: browser.BrowsedURL(),
|
|
}, err
|
|
}
|
|
|
|
func TestIssueCreate(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`),
|
|
)
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, inputs["repositoryId"], "REPOID")
|
|
assert.Equal(t, inputs["title"], "hello")
|
|
assert.Equal(t, inputs["body"], "cash rules everything around me")
|
|
}),
|
|
)
|
|
|
|
output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`, nil)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
func TestIssueCreate_recover(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`))
|
|
// Should only be one fetch of metadata.
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryLabelList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "labels": {
|
|
"nodes": [
|
|
{ "name": "TODO", "id": "TODOID" },
|
|
{ "name": "bug", "id": "BUGID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "recovered title", inputs["title"])
|
|
assert.Equal(t, "recovered body", inputs["body"])
|
|
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
|
|
}))
|
|
|
|
pm := &prompter.PrompterMock{}
|
|
pm.InputFunc = func(p, d string) (string, error) {
|
|
if p == "Title (required)" {
|
|
return d, nil
|
|
} else {
|
|
return "", prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) {
|
|
if p == "Body" {
|
|
return d, nil
|
|
} else {
|
|
return "", prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
|
|
if p == "What's next?" {
|
|
return prompter.IndexFor(opts, "Submit")
|
|
} else {
|
|
return -1, prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
|
|
tmpfile, err := os.CreateTemp(t.TempDir(), "testrecover*")
|
|
assert.NoError(t, err)
|
|
defer tmpfile.Close()
|
|
|
|
state := prShared.IssueMetadataState{
|
|
Title: "recovered title",
|
|
Body: "recovered body",
|
|
Labels: []string{"bug", "TODO"},
|
|
}
|
|
|
|
data, err := json.Marshal(state)
|
|
assert.NoError(t, err)
|
|
|
|
_, err = tmpfile.Write(data)
|
|
assert.NoError(t, err)
|
|
|
|
args := fmt.Sprintf("--recover '%s'", tmpfile.Name())
|
|
|
|
output, err := runCommandWithRootDirOverridden(http, true, args, "", pm)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
func TestIssueCreate_nonLegacyTemplate(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`),
|
|
)
|
|
http.Register(
|
|
httpmock.GraphQL(`query IssueTemplates\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTemplates": [
|
|
{ "name": "Bug report",
|
|
"body": "Does not work :((" },
|
|
{ "name": "Submit a request",
|
|
"body": "I have a suggestion for an enhancement" }
|
|
] } } }`),
|
|
)
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, inputs["repositoryId"], "REPOID")
|
|
assert.Equal(t, inputs["title"], "hello")
|
|
assert.Equal(t, inputs["body"], "I have a suggestion for an enhancement")
|
|
}),
|
|
)
|
|
|
|
pm := &prompter.PrompterMock{}
|
|
pm.MarkdownEditorFunc = func(p, d string, ba bool) (string, error) {
|
|
if p == "Body" {
|
|
return d, nil
|
|
} else {
|
|
return "", prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
|
|
switch p {
|
|
case "What's next?":
|
|
return prompter.IndexFor(opts, "Submit")
|
|
case "Choose a template":
|
|
return prompter.IndexFor(opts, "Submit a request")
|
|
default:
|
|
return -1, prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
|
|
output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./fixtures/repoWithNonLegacyIssueTemplates", pm)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
assert.Equal(t, "", output.BrowsedURL)
|
|
}
|
|
|
|
func TestIssueCreate_continueInBrowser(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }`),
|
|
)
|
|
|
|
pm := &prompter.PrompterMock{}
|
|
pm.InputFunc = func(p, d string) (string, error) {
|
|
if p == "Title (required)" {
|
|
return "hello", nil
|
|
} else {
|
|
return "", prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
|
|
if p == "What's next?" {
|
|
return prompter.IndexFor(opts, "Continue in browser")
|
|
} else {
|
|
return -1, prompter.NoSuchPromptErr(p)
|
|
}
|
|
}
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
output, err := runCommand(http, true, `-b body`, pm)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "", output.String())
|
|
assert.Equal(t, heredoc.Doc(`
|
|
|
|
Creating issue in OWNER/REPO
|
|
|
|
Opening https://github.com/OWNER/REPO/issues/new in your browser.
|
|
`), output.Stderr())
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", output.BrowsedURL)
|
|
}
|
|
|
|
func TestIssueCreate_metadata(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.StubRepoInfoResponse("OWNER", "REPO", "main")
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryLabelList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "labels": {
|
|
"nodes": [
|
|
{ "name": "TODO", "id": "TODOID" },
|
|
{ "name": "bug", "id": "BUGID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.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 }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projects": {
|
|
"nodes": [
|
|
{ "name": "Cleanup", "id": "CLEANUPID" },
|
|
{ "name": "Roadmap", "id": "ROADMAPID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": null },
|
|
"errors": [{
|
|
"type": "NOT_FOUND",
|
|
"path": [ "organization" ],
|
|
"message": "Could not resolve to an Organization with the login of 'OWNER'."
|
|
}]
|
|
}
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projectsV2": {
|
|
"nodes": [],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": { "projectsV2": {
|
|
"nodes": [],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query UserProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "viewer": { "projectsV2": {
|
|
"nodes": [],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"id": "NEWISSUEID",
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "TITLE", inputs["title"])
|
|
assert.Equal(t, "BODY", inputs["body"])
|
|
if v, ok := inputs["assigneeIds"]; ok {
|
|
t.Errorf("did not expect assigneeIds: %v", v)
|
|
}
|
|
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
|
|
assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
|
|
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
|
|
assert.NotContains(t, inputs, "userIds")
|
|
assert.NotContains(t, inputs, "teamIds")
|
|
assert.NotContains(t, inputs, "projectV2Ids")
|
|
}))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
|
assert.Equal(t, []interface{}{"monalisa"}, inputs["actorLogins"])
|
|
}))
|
|
|
|
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`, nil)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
func TestIssueCreate_disabledIssues(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": false
|
|
} } }`),
|
|
)
|
|
|
|
_, err := runCommand(http, true, `-t heres -b johnny`, nil)
|
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIssueCreate_AtMeAssignee(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query UserCurrent\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": {
|
|
"viewer": { "login": "MonaLisa" }
|
|
} }
|
|
`),
|
|
)
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"id": "NEWISSUEID",
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "hello", inputs["title"])
|
|
assert.Equal(t, "cash rules everything around me", inputs["body"])
|
|
if v, ok := inputs["assigneeIds"]; ok {
|
|
t.Errorf("did not expect assigneeIds: %v", v)
|
|
}
|
|
}))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
|
assert.Equal(t, []interface{}{"MonaLisa", "someoneelse"}, inputs["actorLogins"])
|
|
}))
|
|
|
|
output, err := runCommand(http, true, `-a @me -a someoneelse -t hello -b "cash rules everything around me"`, nil)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
func TestIssueCreate_AtCopilotAssignee(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"id": "REPOID",
|
|
"hasIssuesEnabled": true
|
|
} } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"id": "NEWISSUEID",
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "hello", inputs["title"])
|
|
assert.Equal(t, "cash rules everything around me", inputs["body"])
|
|
if v, ok := inputs["assigneeIds"]; ok {
|
|
t.Errorf("did not expect assigneeIds: %v", v)
|
|
}
|
|
}))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
|
assert.Equal(t, []interface{}{"copilot-swe-agent"}, inputs["actorLogins"])
|
|
}))
|
|
|
|
output, err := runCommand(http, true, `-a @copilot -t hello -b "cash rules everything around me"`, nil)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
func TestIssueCreate_projectsV2(t *testing.T) {
|
|
http := &httpmock.Registry{}
|
|
defer http.Verify(t)
|
|
|
|
http.StubRepoInfoResponse("OWNER", "REPO", "main")
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projects": {
|
|
"nodes": [],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": { "projects": {
|
|
"nodes": [],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
|
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "organization": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "TriageV2", "id": "TRIAGEV2ID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`query UserProjectV2List\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "viewer": { "projectsV2": {
|
|
"nodes": [
|
|
{ "title": "MonalisaV2", "id": "MONALISAV2ID" }
|
|
],
|
|
"pageInfo": { "hasNextPage": false }
|
|
} } } }
|
|
`))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "createIssue": { "issue": {
|
|
"id": "Issue#1",
|
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
|
} } } }
|
|
`, func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "TITLE", inputs["title"])
|
|
assert.Equal(t, "BODY", inputs["body"])
|
|
assert.Nil(t, inputs["projectIds"])
|
|
assert.NotContains(t, inputs, "projectV2Ids")
|
|
}))
|
|
http.Register(
|
|
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
|
httpmock.GraphQLQuery(`
|
|
{ "data": { "add_000": { "item": {
|
|
"id": "1"
|
|
} } } }
|
|
`, func(mutations string, inputs map[string]interface{}) {
|
|
variables, err := json.Marshal(inputs)
|
|
assert.NoError(t, err)
|
|
expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}"
|
|
expectedVariables := `{"input_000":{"contentId":"Issue#1","projectId":"ROADMAPV2ID"}}`
|
|
assert.Equal(t, expectedMutations, mutations)
|
|
assert.Equal(t, expectedVariables, string(variables))
|
|
}))
|
|
|
|
output, err := runCommand(http, true, `-t TITLE -b BODY -p roadmapv2`, nil)
|
|
if err != nil {
|
|
t.Errorf("error running command `issue create`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
|
}
|
|
|
|
// TODO projectsV1Deprecation
|
|
// Remove this test.
|
|
func TestProjectsV1Deprecation(t *testing.T) {
|
|
|
|
t.Run("non-interactive submission", func(t *testing.T) {
|
|
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
|
|
reg.Register(
|
|
// ( is required to avoid matching projectsV2
|
|
httpmock.GraphQL(`projects\(`),
|
|
// Simulate a GraphQL error to early exit the test.
|
|
httpmock.StatusStringResponse(500, ""),
|
|
)
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we have no way to really stub it without
|
|
// fully stubbing a GQL error structure in the request body.
|
|
_ = createRun(&CreateOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
},
|
|
BaseRepo: func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
},
|
|
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
Title: "Test Title",
|
|
Body: "Test Body",
|
|
// Required to force a lookup of projects
|
|
Projects: []string{"Project"},
|
|
})
|
|
|
|
// Verify that our request contained projects
|
|
reg.Verify(t)
|
|
})
|
|
|
|
t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
|
|
// ( is required to avoid matching projectsV2
|
|
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we're not really interested in it.
|
|
_ = createRun(&CreateOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
},
|
|
BaseRepo: func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
},
|
|
|
|
Detector: &fd.DisabledDetectorMock{},
|
|
Title: "Test Title",
|
|
Body: "Test Body",
|
|
// Required to force a lookup of projects
|
|
Projects: []string{"Project"},
|
|
})
|
|
|
|
// Verify that our request contained projectCards
|
|
reg.Verify(t)
|
|
})
|
|
})
|
|
|
|
t.Run("web mode", func(t *testing.T) {
|
|
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.Register(
|
|
// ( is required to avoid matching projectsV2
|
|
httpmock.GraphQL(`projects\(`),
|
|
// Simulate a GraphQL error to early exit the test.
|
|
httpmock.StatusStringResponse(500, ""),
|
|
)
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we have no way to really stub it without
|
|
// fully stubbing a GQL error structure in the request body.
|
|
_ = createRun(&CreateOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
},
|
|
BaseRepo: func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
},
|
|
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
WebMode: true,
|
|
// Required to force a lookup of projects
|
|
Projects: []string{"Project"},
|
|
})
|
|
|
|
// Verify that our request contained projects
|
|
reg.Verify(t)
|
|
})
|
|
|
|
t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
// ( is required to avoid matching projectsV2
|
|
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we're not really interested in it.
|
|
_ = createRun(&CreateOptions{
|
|
IO: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
},
|
|
BaseRepo: func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
},
|
|
|
|
Detector: &fd.DisabledDetectorMock{},
|
|
WebMode: true,
|
|
// Required to force a lookup of projects
|
|
Projects: []string{"Project"},
|
|
})
|
|
|
|
// Verify that our request contained projectCards
|
|
reg.Verify(t)
|
|
})
|
|
})
|
|
}
|