Let the API return its own "unsupported" error rather than gating the relationship mutations behind a client-side IssueRelationships check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1734 lines
50 KiB
Go
1734 lines
50 KiB
Go
package edit
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
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/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/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
|
|
expectedBaseRepo ghrepo.Interface
|
|
wantsErr bool
|
|
}{
|
|
{
|
|
name: "no argument",
|
|
input: "",
|
|
output: EditOptions{},
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "issue number argument",
|
|
input: "23",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Interactive: true,
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "title flag",
|
|
input: "23 --title test",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Title: prShared.EditableString{
|
|
Value: "test",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "body flag",
|
|
input: "23 --body test",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Body: prShared.EditableString{
|
|
Value: "a body from file",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "both body and body-file flags",
|
|
input: "23 --body foo --body-file bar",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "add-assignee flag",
|
|
input: "23 --add-assignee monalisa,hubot",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Add: []string{"monalisa", "hubot"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "remove-assignee flag",
|
|
input: "23 --remove-assignee monalisa,hubot",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Remove: []string{"monalisa", "hubot"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "add-label flag",
|
|
input: "23 --add-label feature,TODO,bug",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{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{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Milestone: prShared.EditableString{
|
|
Value: "GA",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "remove-milestone flag",
|
|
input: "23 --remove-milestone",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
Milestone: prShared.EditableString{
|
|
Value: "",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "both milestone and remove-milestone flags",
|
|
input: "23 --milestone foo --remove-milestone",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "add label to multiple issues",
|
|
input: "23 34 --add-label bug",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23, 34},
|
|
Editable: prShared.Editable{
|
|
Labels: prShared.EditableSlice{
|
|
Add: []string{"bug"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "argument is hash prefixed number",
|
|
// Escaping is required here to avoid what I think is shellex treating it as a comment.
|
|
input: "\\#23",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Interactive: true,
|
|
},
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "argument is a URL",
|
|
input: "https://example.com/cli/cli/issues/23",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Interactive: true,
|
|
},
|
|
expectedBaseRepo: ghrepo.NewWithHost("cli", "cli", "example.com"),
|
|
wantsErr: false,
|
|
},
|
|
{
|
|
name: "URL arguments parse as different repos",
|
|
input: "https://github.com/cli/cli/issues/23 https://github.com/cli/go-gh/issues/23",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "interactive multiple issues",
|
|
input: "23 34",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "type flag",
|
|
input: "23 --type Bug",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
Editable: prShared.Editable{
|
|
IssueType: prShared.EditableString{
|
|
Value: "Bug",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "set-parent flag",
|
|
input: "23 --set-parent 100",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
SetParent: "100",
|
|
Editable: prShared.Editable{
|
|
Parent: prShared.EditableString{
|
|
Value: "100",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "remove-parent flag",
|
|
input: "23 --remove-parent",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
RemoveParent: true,
|
|
Editable: prShared.Editable{
|
|
Parent: prShared.EditableString{
|
|
Value: "",
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "both set-parent and remove-parent flags",
|
|
input: "23 --set-parent 100 --remove-parent",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "add-sub-issue flag",
|
|
input: "23 --add-sub-issue 123,124",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
AddSubIssues: []string{"123", "124"},
|
|
},
|
|
},
|
|
{
|
|
name: "add-sub-issue rejected with multiple issues",
|
|
input: "23 24 --add-sub-issue 123",
|
|
wantsErr: true,
|
|
},
|
|
{
|
|
name: "remove-sub-issue flag",
|
|
input: "23 --remove-sub-issue 50",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
RemoveSubIssues: []string{"50"},
|
|
},
|
|
},
|
|
{
|
|
name: "add-blocked-by flag",
|
|
input: "23 --add-blocked-by 200",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
AddBlockedBy: []string{"200"},
|
|
},
|
|
},
|
|
{
|
|
name: "remove-blocked-by flag",
|
|
input: "23 --remove-blocked-by 201",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
RemoveBlockedBy: []string{"201"},
|
|
},
|
|
},
|
|
{
|
|
name: "add-blocking flag",
|
|
input: "23 --add-blocking 300,301",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
AddBlocking: []string{"300", "301"},
|
|
},
|
|
},
|
|
{
|
|
name: "remove-blocking flag",
|
|
input: "23 --remove-blocking 300",
|
|
output: EditOptions{
|
|
IssueNumbers: []int{23},
|
|
RemoveBlocking: []string{"300"},
|
|
},
|
|
},
|
|
}
|
|
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 {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers)
|
|
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
|
|
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
|
|
assert.Equal(t, tt.output.SetParent, gotOpts.SetParent)
|
|
assert.Equal(t, tt.output.RemoveParent, gotOpts.RemoveParent)
|
|
assert.Equal(t, tt.output.AddSubIssues, gotOpts.AddSubIssues)
|
|
assert.Equal(t, tt.output.RemoveSubIssues, gotOpts.RemoveSubIssues)
|
|
assert.Equal(t, tt.output.AddBlockedBy, gotOpts.AddBlockedBy)
|
|
assert.Equal(t, tt.output.RemoveBlockedBy, gotOpts.RemoveBlockedBy)
|
|
assert.Equal(t, tt.output.AddBlocking, gotOpts.AddBlocking)
|
|
assert.Equal(t, tt.output.RemoveBlocking, gotOpts.RemoveBlocking)
|
|
if tt.expectedBaseRepo != nil {
|
|
baseRepo, err := gotOpts.BaseRepo()
|
|
require.NoError(t, err)
|
|
require.True(
|
|
t,
|
|
ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
|
|
"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
Title: prShared.EditableString{
|
|
Value: "new title",
|
|
Edited: true,
|
|
},
|
|
Body: prShared.EditableString{
|
|
Value: "new body",
|
|
Edited: true,
|
|
},
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: 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)
|
|
mockIssueUpdateApiActors(t, reg)
|
|
mockIssueUpdateLabels(t, reg)
|
|
mockProjectV2ItemUpdate(t, reg)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "non-interactive multiple issues",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{456, 123},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: 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)
|
|
mockIssueUpdateApiActors(t, reg)
|
|
mockIssueUpdateApiActors(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{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123, 9999},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: 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{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123, 456},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: 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 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 ReplaceActorsForAssignable\b`, func(m map[string]interface{}) bool {
|
|
return m["assignableId"] == "123"
|
|
}),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }`,
|
|
func(inputs map[string]interface{}) {}),
|
|
)
|
|
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 ReplaceActorsForAssignable\b`, func(m map[string]interface{}) bool {
|
|
return m["assignableId"] == "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{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{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)
|
|
mockIssueUpdateApiActors(t, reg)
|
|
mockIssueUpdateLabels(t, reg)
|
|
mockProjectV2ItemUpdate(t, reg)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "interactive prompts with actor assignee display names when actors available",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: true,
|
|
FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
|
|
eo.Assignees.Edited = true
|
|
return nil
|
|
},
|
|
EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
|
|
// Checking that the display name is being used in the prompt.
|
|
require.Equal(t, []string{"hubot"}, eo.Assignees.Default)
|
|
require.Equal(t, []string{"hubot"}, eo.Assignees.DefaultLogins)
|
|
|
|
// Adding MonaLisa as issue assignee, should preserve hubot.
|
|
// MultiSelectWithSearch returns Keys (logins), not display names.
|
|
eo.Assignees.Value = []string{"hubot", "MonaLisa"}
|
|
return nil
|
|
},
|
|
FetchOptions: prShared.FetchOptions,
|
|
DetermineEditor: func() (string, error) { return "vim", nil },
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIsssueNumberGetWithAssignedActors(t, reg, 123)
|
|
mockIssueUpdate(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
require.Subset(t, inputs["actorLogins"], []interface{}{"hubot", "MonaLisa"})
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "interactive prompts with user assignee logins when actors unavailable",
|
|
input: &EditOptions{
|
|
IssueNumbers: []int{123},
|
|
Interactive: true,
|
|
FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
|
|
eo.Assignees.Edited = true
|
|
return nil
|
|
},
|
|
EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
|
|
// Checking that only the login is used in the prompt (no display name)
|
|
require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa"})
|
|
|
|
// Mocking a selection of only MonaLisa in the prompt.
|
|
eo.Assignees.Value = []string{"MonaLisa"}
|
|
return nil
|
|
},
|
|
FetchOptions: prShared.FetchOptions,
|
|
DetermineEditor: func() (string, error) { return "vim", nil },
|
|
Detector: &fd.DisabledDetectorMock{},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
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/123",
|
|
"assignees": {
|
|
"nodes": [
|
|
{
|
|
"id": "HUBOTID",
|
|
"login": "hubot",
|
|
"name": ""
|
|
},
|
|
{
|
|
"id": "MONAID",
|
|
"login": "MonaLisa",
|
|
"name": "Mona Display Name"
|
|
}
|
|
],
|
|
"totalCount": 2
|
|
}
|
|
} } } }`, 123)),
|
|
)
|
|
reg.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 }
|
|
} } } }
|
|
`))
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation IssueUpdate\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "updateIssue": { "__typename": "" } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
// Checking that we still assigned the expected ID.
|
|
require.Contains(t, inputs["assigneeIds"], "MONAID")
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit type",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
IssueType: prShared.EditableString{
|
|
Value: "Bug",
|
|
Edited: true,
|
|
},
|
|
},
|
|
FetchOptions: prShared.FetchOptions,
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTypes": { "nodes": [
|
|
{ "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" },
|
|
{ "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" }
|
|
] } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateIssueIssueType\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "123", inputs["issueId"])
|
|
assert.Equal(t, "BUG_TYPE_ID", inputs["issueTypeId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "interactive edit type prompt",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: true,
|
|
FieldsToEditSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable) error {
|
|
// Verify the survey is allowed to offer Type as an option for issue edit.
|
|
assert.True(t, eo.IssueType.Allowed)
|
|
eo.IssueType.Edited = true
|
|
return nil
|
|
},
|
|
EditFieldsSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable, _ string) error {
|
|
// FetchOptions populated Options and IssueTypeNameToID from
|
|
// the RepositoryIssueTypes stub below.
|
|
assert.Equal(t, []string{"Bug", "Feature"}, eo.IssueType.Options)
|
|
assert.Equal(t, "FEATURE_TYPE_ID", eo.IssueTypeNameToID["Feature"])
|
|
eo.IssueType.Value = "Feature"
|
|
return nil
|
|
},
|
|
FetchOptions: prShared.FetchOptions,
|
|
DetermineEditor: func() (string, error) { return "vim", nil },
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTypes": { "nodes": [
|
|
{ "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" },
|
|
{ "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" }
|
|
] } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateIssueIssueType\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "123", inputs["issueId"])
|
|
assert.Equal(t, "FEATURE_TYPE_ID", inputs["issueTypeId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "interactive edit parent prompt",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: true,
|
|
FieldsToEditSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable) error {
|
|
// Verify the survey is allowed to offer Parent as an option for issue edit.
|
|
assert.True(t, eo.Parent.Allowed)
|
|
eo.Parent.Edited = true
|
|
return nil
|
|
},
|
|
EditFieldsSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable, _ string) error {
|
|
eo.Parent.Value = "100"
|
|
return nil
|
|
},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
DetermineEditor: func() (string, error) { return "vim", nil },
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "PARENT_100_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "PARENT_100_ID", inputs["issueId"])
|
|
assert.Equal(t, "123", inputs["subIssueId"])
|
|
assert.Equal(t, true, inputs["replaceParent"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit set parent",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
Parent: prShared.EditableString{
|
|
Value: "100",
|
|
Edited: true,
|
|
},
|
|
},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "PARENT_100_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "PARENT_100_ID", inputs["issueId"])
|
|
assert.Equal(t, "123", inputs["subIssueId"])
|
|
assert.Equal(t, true, inputs["replaceParent"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit remove parent",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
RemoveParent: true,
|
|
Editable: prShared.Editable{
|
|
Parent: prShared.EditableString{
|
|
Value: "",
|
|
Edited: true,
|
|
},
|
|
},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueByNumber\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
|
"id": "123",
|
|
"number": 123,
|
|
"url": "https://github.com/OWNER/REPO/issue/123",
|
|
"parent": {
|
|
"id": "PARENT_100_ID",
|
|
"number": 100,
|
|
"title": "Parent Issue",
|
|
"url": "https://github.com/OWNER/REPO/issues/100",
|
|
"state": "OPEN",
|
|
"repository": { "nameWithOwner": "OWNER/REPO" }
|
|
}
|
|
} } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation RemoveSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "removeSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "PARENT_100_ID", inputs["issueId"])
|
|
assert.Equal(t, "123", inputs["subIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit add sub-issues",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{100},
|
|
Interactive: false,
|
|
AddSubIssues: []string{"123", "124"},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueNumberGet(t, reg, 100)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "100", inputs["issueId"])
|
|
assert.Equal(t, "SUB_123_ID", inputs["subIssueId"])
|
|
assert.Equal(t, false, inputs["replaceParent"])
|
|
}),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "SUB_124_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "100", inputs["issueId"])
|
|
assert.Equal(t, "SUB_124_ID", inputs["subIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/100\n",
|
|
},
|
|
{
|
|
name: "edit remove sub-issue",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{100},
|
|
Interactive: false,
|
|
RemoveSubIssues: []string{"123"},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueNumberGet(t, reg, 100)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation RemoveSubIssue\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "removeSubIssue": { "issue": { "id": "100" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "100", inputs["issueId"])
|
|
assert.Equal(t, "SUB_123_ID", inputs["subIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/100\n",
|
|
},
|
|
{
|
|
name: "edit add and remove blocked-by",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
AddBlockedBy: []string{"200"},
|
|
RemoveBlockedBy: []string{"201"},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "BLOCKING_200_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddBlockedBy\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addBlockedBy": { "issue": { "id": "123" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "123", inputs["issueId"])
|
|
assert.Equal(t, "BLOCKING_200_ID", inputs["blockingIssueId"])
|
|
}),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "BLOCKING_201_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation RemoveBlockedBy\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "removeBlockedBy": { "issue": { "id": "123" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
assert.Equal(t, "123", inputs["issueId"])
|
|
assert.Equal(t, "BLOCKING_201_ID", inputs["blockingIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit add blocking swaps args",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
AddBlocking: []string{"300"},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation AddBlockedBy\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
// --add-blocking swaps: OTHER issue is blocked BY this issue
|
|
assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"])
|
|
assert.Equal(t, "123", inputs["blockingIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "edit remove blocking swaps args",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123},
|
|
Interactive: false,
|
|
RemoveBlocking: []string{"300"},
|
|
FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
},
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
mockIssueGet(t, reg)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueNodeID\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } }
|
|
`),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation RemoveBlockedBy\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "removeBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`,
|
|
func(inputs map[string]interface{}) {
|
|
// --remove-blocking swaps: OTHER issue is no longer blocked BY this issue
|
|
assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"])
|
|
assert.Equal(t, "123", inputs["blockingIssueId"])
|
|
}),
|
|
)
|
|
},
|
|
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
|
},
|
|
{
|
|
name: "batch edit type",
|
|
input: &EditOptions{
|
|
Detector: &fd.EnabledDetectorMock{},
|
|
IssueNumbers: []int{123, 456},
|
|
Interactive: false,
|
|
Editable: prShared.Editable{
|
|
IssueType: prShared.EditableString{
|
|
Value: "Bug",
|
|
Edited: true,
|
|
},
|
|
},
|
|
FetchOptions: prShared.FetchOptions,
|
|
},
|
|
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": { "issueTypes": { "nodes": [
|
|
{ "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }
|
|
] } } } }
|
|
`),
|
|
)
|
|
mockIssueNumberGet(t, reg, 123)
|
|
mockIssueNumberGet(t, reg, 456)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateIssueIssueType\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`,
|
|
func(inputs map[string]interface{}) {}),
|
|
)
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateIssueIssueType\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "updateIssueIssueType": { "issue": { "id": "456" } } } }`,
|
|
func(inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
stdout: heredoc.Doc(`
|
|
https://github.com/OWNER/REPO/issue/123
|
|
https://github.com/OWNER/REPO/issue/456
|
|
`),
|
|
},
|
|
}
|
|
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 mockIsssueNumberGetWithAssignedActors(_ *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",
|
|
"assignedActors": {
|
|
"nodes": [
|
|
{
|
|
"id": "HUBOTID",
|
|
"login": "hubot",
|
|
"__typename": "Bot"
|
|
}
|
|
],
|
|
"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 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 mockIssueUpdateApiActors(t *testing.T, reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
|
httpmock.GraphQLMutation(`
|
|
{ "data": { "replaceActorsForAssignable": { "__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{}) {}),
|
|
)
|
|
}
|
|
|
|
// Test_editRun_crossHostRelationshipRefs verifies that every relationship
|
|
// flag rejects a cross-host issue URL with the same clear error. Lives as
|
|
// its own table rather than additional cases in Test_editRun because each
|
|
// case shares identical setup and asserts the same error, varying only in
|
|
// which input field carries the cross-host URL.
|
|
func Test_editRun_crossHostRelationshipRefs(t *testing.T) {
|
|
const crossHostURL = "https://example.com/OWNER/REPO/issues/9"
|
|
|
|
// Each case exercises one relationship-bearing flag with a cross-host
|
|
// URL. ResolveIssueRef should short-circuit before any GraphQL request,
|
|
// and the per-issue failure must surface to stderr.
|
|
tests := []struct {
|
|
name string
|
|
input *EditOptions
|
|
}{
|
|
{
|
|
name: "set parent",
|
|
input: &EditOptions{
|
|
Editable: prShared.Editable{
|
|
Parent: prShared.EditableString{
|
|
Value: crossHostURL,
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "add sub-issue",
|
|
input: &EditOptions{AddSubIssues: []string{crossHostURL}},
|
|
},
|
|
{
|
|
name: "remove sub-issue",
|
|
input: &EditOptions{RemoveSubIssues: []string{crossHostURL}},
|
|
},
|
|
{
|
|
name: "add blocked-by",
|
|
input: &EditOptions{AddBlockedBy: []string{crossHostURL}},
|
|
},
|
|
{
|
|
name: "remove blocked-by",
|
|
input: &EditOptions{RemoveBlockedBy: []string{crossHostURL}},
|
|
},
|
|
{
|
|
name: "add blocking",
|
|
input: &EditOptions{AddBlocking: []string{crossHostURL}},
|
|
},
|
|
{
|
|
name: "remove blocking",
|
|
input: &EditOptions{RemoveBlocking: []string{crossHostURL}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, _, _, stderr := iostreams.Test()
|
|
ios.SetStdoutTTY(true)
|
|
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
mockIssueGet(t, reg)
|
|
// No IssueNodeID stub on purpose: the cross-host guard must
|
|
// short-circuit before any resolution request goes out.
|
|
|
|
tt.input.Detector = &fd.EnabledDetectorMock{}
|
|
tt.input.IssueNumbers = []int{123}
|
|
tt.input.Interactive = false
|
|
tt.input.FetchOptions = func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
|
|
return nil
|
|
}
|
|
tt.input.IO = ios
|
|
tt.input.HttpClient = func() (*http.Client, error) {
|
|
return &http.Client{Transport: reg}, nil
|
|
}
|
|
tt.input.BaseRepo = func() (ghrepo.Interface, error) {
|
|
return ghrepo.New("OWNER", "REPO"), nil
|
|
}
|
|
|
|
err := editRun(tt.input)
|
|
require.Error(t, err)
|
|
assert.Regexp(t, `belongs to a different host \(example\.com\) than the current repository \(github\.com\)`, stderr.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApiActorsSupported(t *testing.T) {
|
|
t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.Register(
|
|
httpmock.GraphQL(`assignedActors`),
|
|
// Simulate a GraphQL error to early exit the test.
|
|
httpmock.StatusStringResponse(500, ""),
|
|
)
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we don't care.
|
|
_ = editRun(&EditOptions{
|
|
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{},
|
|
IssueNumbers: []int{123},
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Add: []string{"monalisa", "octocat"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
reg.Verify(t)
|
|
})
|
|
|
|
t.Run("when actors are not assignable, query includes assignees instead", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
// This test should NOT include assignedActors in the query
|
|
reg.Exclude(t, httpmock.GraphQL(`assignedActors`))
|
|
// It should include the regular assignees field
|
|
reg.Register(
|
|
httpmock.GraphQL(`assignees`),
|
|
// Simulate a GraphQL error to early exit the test.
|
|
httpmock.StatusStringResponse(500, ""),
|
|
)
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we're not really interested in it.
|
|
_ = editRun(&EditOptions{
|
|
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{},
|
|
IssueNumbers: []int{123},
|
|
Editable: prShared.Editable{
|
|
Assignees: prShared.EditableAssignees{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Add: []string{"monalisa", "octocat"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
reg.Verify(t)
|
|
})
|
|
}
|
|
|
|
// TODO projectsV1Deprecation
|
|
// Remove this test.
|
|
func TestProjectsV1Deprecation(t *testing.T) {
|
|
t.Run("when projects v1 is supported, is included in query", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.Register(
|
|
httpmock.GraphQL(`projectCards`),
|
|
// 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.
|
|
_ = editRun(&EditOptions{
|
|
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{},
|
|
|
|
IssueNumbers: []int{123},
|
|
Editable: prShared.Editable{
|
|
Projects: prShared.EditableProjects{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Add: []string{"Test Project"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Verify that our request contained projectCards
|
|
reg.Verify(t)
|
|
})
|
|
|
|
t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) {
|
|
ios, _, _, _ := iostreams.Test()
|
|
|
|
reg := &httpmock.Registry{}
|
|
reg.Exclude(t, httpmock.GraphQL(`projectCards`))
|
|
|
|
reg.Register(
|
|
httpmock.GraphQL(`.*`),
|
|
// Simulate a GraphQL error to early exit the test.
|
|
httpmock.StatusStringResponse(500, ""),
|
|
)
|
|
|
|
_, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
// Ignore the error because we're not really interested in it.
|
|
_ = editRun(&EditOptions{
|
|
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{},
|
|
|
|
IssueNumbers: []int{123},
|
|
Editable: prShared.Editable{
|
|
Projects: prShared.EditableProjects{
|
|
EditableSlice: prShared.EditableSlice{
|
|
Add: []string{"Test Project"},
|
|
Edited: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Verify that our request contained projectCards
|
|
reg.Verify(t)
|
|
})
|
|
}
|