test: add comprehensive tests for Issues 2.0 features

Create tests (11 new):
- Flag parsing: --type, --parent (number/URL), --blocked-by, --blocking
- Behavior: type resolution + mutation, type not found error,
  parent resolution + addSubIssue, blocked-by/blocking with
  swapped args verification, GHES unsupported error

Edit tests (18 new):
- Flag parsing: --type, --set-parent, --remove-parent, mutual
  exclusivity, --add-sub-issue, --remove-sub-issue,
  --add-blocked-by, --remove-blocked-by, --add-blocking
- Behavior: type edit, set/remove parent, add/remove sub-issues,
  add/remove blocked-by, add-blocking with swapped args,
  batch edit type across multiple issues
- Bug fix: copy SetParent value into Editable.Parent.Value

View tests (5 new):
- TTY: full view with all Issues 2.0 fields, regression test
  with no new fields
- JSON: issueType export, parent/subIssues/subIssuesSummary
  export, blockedBy/blocking export

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-03-29 17:02:52 -06:00
parent 67f63a1096
commit 4c12393b88
4 changed files with 1073 additions and 0 deletions

View file

@ -1262,6 +1262,348 @@ func TestIssueCreate_projectsV2(t *testing.T) {
// TODO projectsV1Deprecation
// Remove this test.
func TestNewCmdCreate_issuesV2Flags(t *testing.T) {
tests := []struct {
name string
cli string
wantsErr bool
wantsType string
wantsParent string
wantsBlocking []string
wantsBlockBy []string
}{
{
name: "type flag",
cli: `-t mytitle -b mybody --type Bug`,
wantsType: "Bug",
},
{
name: "parent flag with number",
cli: `-t mytitle -b mybody --parent 100`,
wantsParent: "100",
},
{
name: "parent flag with URL",
cli: `-t mytitle -b mybody --parent https://github.com/cli/go-gh/issues/42`,
wantsParent: "https://github.com/cli/go-gh/issues/42",
},
{
name: "blocked-by flag",
cli: `-t mytitle -b mybody --blocked-by 200,201`,
wantsBlockBy: []string{"200", "201"},
},
{
name: "blocking flag",
cli: `-t mytitle -b mybody --blocking 300`,
wantsBlocking: []string{"300"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: ios,
Config: func() (gh.Config, error) {
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
}
require.NoError(t, err)
assert.Equal(t, tt.wantsType, opts.IssueType)
assert.Equal(t, tt.wantsParent, opts.Parent)
assert.Equal(t, tt.wantsBlocking, opts.Blocking)
assert.Equal(t, tt.wantsBlockBy, opts.BlockedBy)
})
}
}
func Test_createRun_issuesV2(t *testing.T) {
tests := []struct {
name string
opts CreateOptions
httpStubs func(*testing.T, *httpmock.Registry)
wantsStdout string
wantsStderr string
wantsErr string
}{
{
name: "create with type",
opts: CreateOptions{
Detector: &fd.EnabledDetectorMock{},
Title: "bug title",
Body: "bug body",
IssueType: "Bug",
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
r.Register(
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issueTypes": { "nodes": [
{ "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" },
{ "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" },
{ "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" }
] } } } }`))
r.Register(
httpmock.GraphQL(`mutation UpdateIssueIssueType\b`),
httpmock.GraphQLMutation(`
{ "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "ISSUE_ID_123", inputs["issueId"])
assert.Equal(t, "IT_1", inputs["issueTypeId"])
}))
},
wantsStdout: "https://github.com/OWNER/REPO/issues/123\n",
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
},
{
name: "create with type not found",
opts: CreateOptions{
Detector: &fd.EnabledDetectorMock{},
Title: "bug title",
Body: "bug body",
IssueType: "Bugz",
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
r.Register(
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issueTypes": { "nodes": [
{ "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" },
{ "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" },
{ "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" }
] } } } }`))
},
wantsErr: `type "Bugz" not found; available types: Bug, Feature, Task`,
},
{
name: "create with parent",
opts: CreateOptions{
Detector: &fd.EnabledDetectorMock{},
Title: "child issue",
Body: "child body",
Parent: "100",
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
r.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "PARENT_ID_100" } } } }`))
r.Register(
httpmock.GraphQL(`mutation AddSubIssue\b`),
httpmock.GraphQLMutation(`
{ "data": { "addSubIssue": { "issue": { "id": "PARENT_ID_100" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "PARENT_ID_100", inputs["issueId"])
assert.Equal(t, "ISSUE_ID_123", inputs["subIssueId"])
assert.Equal(t, false, inputs["replaceParent"])
}))
},
wantsStdout: "https://github.com/OWNER/REPO/issues/123\n",
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
},
{
name: "create with blocked-by and blocking",
opts: CreateOptions{
Detector: &fd.EnabledDetectorMock{},
Title: "blocked issue",
Body: "blocked body",
BlockedBy: []string{"200"},
Blocking: []string{"300"},
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
// IssueNodeID for --blocked-by 200
r.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_200" } } } }`))
// AddBlockedBy for --blocked-by: new issue is blocked by #200
r.Register(
httpmock.GraphQL(`mutation AddBlockedBy\b`),
httpmock.GraphQLMutation(`
{ "data": { "addBlockedBy": { "blockedIssue": { "id": "ISSUE_ID_123" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "ISSUE_ID_123", inputs["issueId"])
assert.Equal(t, "BLOCKER_ID_200", inputs["blockingIssueId"])
}))
// IssueNodeID for --blocking 300
r.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "BLOCKED_ID_300" } } } }`))
// AddBlockedBy for --blocking: #300 is blocked by new issue (swapped args)
r.Register(
httpmock.GraphQL(`mutation AddBlockedBy\b`),
httpmock.GraphQLMutation(`
{ "data": { "addBlockedBy": { "blockedIssue": { "id": "BLOCKED_ID_300" } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "BLOCKED_ID_300", inputs["issueId"])
assert.Equal(t, "ISSUE_ID_123", inputs["blockingIssueId"])
}))
},
wantsStdout: "https://github.com/OWNER/REPO/issues/123\n",
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
},
{
name: "blocked-by unsupported on GHES",
opts: CreateOptions{
Detector: &fd.DisabledDetectorMock{},
Title: "blocked issue",
Body: "blocked body",
BlockedBy: []string{"200"},
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
},
wantsErr: "issue relationships are not supported on this GitHub Enterprise Server version",
},
{
name: "blocking unsupported on GHES",
opts: CreateOptions{
Detector: &fd.DisabledDetectorMock{},
Title: "blocking issue",
Body: "blocking body",
Blocking: []string{"300"},
},
httpStubs: func(t *testing.T, r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query IssueRepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`))
r.Register(
httpmock.GraphQL(`mutation IssueCreate\b`),
httpmock.StringResponse(`
{ "data": { "createIssue": { "issue": {
"id": "ISSUE_ID_123",
"URL": "https://github.com/OWNER/REPO/issues/123"
} } } }`))
},
wantsErr: "issue relationships are not supported on this GitHub Enterprise Server version",
},
}
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(t, 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
}
opts.Browser = &browser.Stub{}
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())
})
}
}
func TestProjectsV1Deprecation(t *testing.T) {
t.Run("non-interactive submission", func(t *testing.T) {

View file

@ -176,6 +176,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
opts.Editable.Parent.Edited = true
if opts.RemoveParent {
opts.Editable.Parent.Value = ""
} else {
opts.Editable.Parent.Value = opts.SetParent
}
}

View file

@ -11,6 +11,7 @@ import (
"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"
@ -281,6 +282,92 @@ func TestNewCmdEdit(t *testing.T) {
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: "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"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -322,6 +409,13 @@ func TestNewCmdEdit(t *testing.T) {
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)
if tt.expectedBaseRepo != nil {
baseRepo, err := gotOpts.BaseRepo()
require.NoError(t, err)
@ -720,6 +814,345 @@ func Test_editRun(t *testing.T) {
},
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: 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 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: "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": {
"number": 100,
"title": "Parent Issue",
"url": "https://github.com/OWNER/REPO/issues/100",
"state": "OPEN",
"repository": { "nameWithOwner": "OWNER/REPO" }
}
} } } }
`),
)
reg.Register(
httpmock.GraphQL(`query IssueNodeID\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issue": { "id": "PARENT_100_ID" } } } }
`),
)
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": { "blockedIssue": { "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": { "blockedIssue": { "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": { "blockedIssue": { "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: "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: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error {
return nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueNumberGet(t, reg, 123)
mockIssueNumberGet(t, reg, 456)
reg.Register(
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issueTypes": { "nodes": [
{ "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }
] } } } }
`),
)
reg.Register(
httpmock.GraphQL(`query RepositoryIssueTypes\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "issueTypes": { "nodes": [
{ "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }
] } } } }
`),
)
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) {

View file

@ -2,6 +2,7 @@ package view
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@ -21,6 +22,7 @@ import (
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONFields(t *testing.T) {
@ -641,3 +643,297 @@ func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) {
} } } } }
`))
}
// issueResponseAllIssues2Fields returns a GraphQL response for an issue with all Issues 2.0 fields populated.
func issueResponseAllIssues2Fields() string {
return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"id": "ISSUE_123",
"number": 123,
"title": "Implement OAuth flow",
"state": "OPEN",
"stateReason": "",
"body": "The OAuth flow needs work.",
"author": {"login": "user1"},
"createdAt": "2024-01-01T00:00:00Z",
"comments": {"nodes":[], "totalCount": 0},
"assignees": {"nodes": [], "totalCount": 0},
"labels": {"nodes": [], "totalCount": 0},
"milestone": null,
"reactionGroups": [],
"projectCards": {"nodes": [], "totalCount": 0},
"projectItems": {"nodes": [], "totalCount": 0},
"url": "https://github.com/OWNER/REPO/issues/123",
"issueType": {"id":"IT_1","name":"Bug","description":"Something is not working","color":"d73a4a"},
"parent": {"number":100,"title":"Epic: Authentication overhaul","url":"https://github.com/OWNER/REPO/issues/100","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}},
"subIssues": {
"nodes": [
{"number":101,"title":"Design auth module","url":"https://github.com/OWNER/REPO/issues/101","state":"CLOSED","repository":{"nameWithOwner":"OWNER/REPO"}},
{"number":102,"title":"Token refresh logic","url":"https://github.com/OWNER/REPO/issues/102","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}
],
"totalCount": 2
},
"subIssuesSummary": {"total":2,"completed":1,"percentCompleted":50.0},
"blockedBy": {
"nodes": [{"number":200,"title":"API rate limiting","url":"https://github.com/OWNER/REPO/issues/200","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}]
},
"blocking": {
"nodes": [{"number":300,"title":"Release v2.0","url":"https://github.com/OWNER/REPO/issues/300","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}]
}
} } } }`
}
// issueResponseNoIssues2Fields returns a GraphQL response for an issue with no Issues 2.0 fields.
func issueResponseNoIssues2Fields() string {
return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"id": "ISSUE_456",
"number": 456,
"title": "Fix login page",
"state": "OPEN",
"stateReason": "",
"body": "The login page is broken.",
"author": {"login": "user2"},
"createdAt": "2024-01-01T00:00:00Z",
"comments": {"nodes":[], "totalCount": 2},
"assignees": {"nodes": [], "totalCount": 0},
"labels": {"nodes": [], "totalCount": 0},
"milestone": null,
"reactionGroups": [],
"projectCards": {"nodes": [], "totalCount": 0},
"projectItems": {"nodes": [], "totalCount": 0},
"url": "https://github.com/OWNER/REPO/issues/456"
} } } }`
}
func TestIssueView_tty_Issues2AllFields(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseAllIssues2Fields()),
)
mockEmptyV2ProjectItems(t, httpReg)
opts := ViewOptions{
IO: ios,
Now: func() time.Time {
t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC")
return t
},
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: httpReg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IssueNumber: 123,
}
err := viewRun(&opts)
require.NoError(t, err)
assert.Equal(t, "", stderr.String())
out := stdout.String()
// Title
assert.Contains(t, out, "Implement OAuth flow")
assert.Contains(t, out, "OWNER/REPO#123")
// State line includes issue type prefix
assert.Contains(t, out, "Bug · Open")
// Type metadata row
assert.Contains(t, out, "Type:")
assert.Contains(t, out, "Bug")
// Parent metadata row
assert.Contains(t, out, "Parent:")
assert.Contains(t, out, "OWNER/REPO#100 Epic: Authentication overhaul")
// Blocked by metadata row
assert.Contains(t, out, "Blocked by:")
assert.Contains(t, out, "OWNER/REPO#200 API rate limiting")
// Blocking metadata row
assert.Contains(t, out, "Blocking:")
assert.Contains(t, out, "OWNER/REPO#300 Release v2.0")
// Sub-issues section
assert.Contains(t, out, "Sub-issues")
assert.Contains(t, out, "1/2 (50%)")
assert.Contains(t, out, "OWNER/REPO#101")
assert.Contains(t, out, "Design auth module")
assert.Contains(t, out, "OWNER/REPO#102")
assert.Contains(t, out, "Token refresh logic")
// Body
assert.Contains(t, out, "The OAuth flow needs work.")
// Footer
assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_tty_Issues2NoFields(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseNoIssues2Fields()),
)
mockEmptyV2ProjectItems(t, httpReg)
opts := ViewOptions{
IO: ios,
Now: func() time.Time {
t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC")
return t
},
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: httpReg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IssueNumber: 456,
}
err := viewRun(&opts)
require.NoError(t, err)
assert.Equal(t, "", stderr.String())
out := stdout.String()
// Standard fields are still present
assert.Contains(t, out, "Fix login page")
assert.Contains(t, out, "OWNER/REPO#456")
assert.Contains(t, out, "Open")
assert.Contains(t, out, "The login page is broken.")
assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/456")
// Issues 2.0 sections must NOT appear
assert.NotContains(t, out, "Type:")
assert.NotContains(t, out, "Parent:")
assert.NotContains(t, out, "Blocked by:")
assert.NotContains(t, out, "Blocking:")
assert.NotContains(t, out, "Sub-issues")
}
func TestIssueView_json_IssueType(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseAllIssues2Fields()),
)
output, err := runCommand(httpReg, false, `123 --json issueType`)
require.NoError(t, err)
var data map[string]interface{}
require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data))
issueType, ok := data["issueType"].(map[string]interface{})
require.True(t, ok, "issueType should be an object")
assert.Equal(t, "IT_1", issueType["id"])
assert.Equal(t, "Bug", issueType["name"])
assert.Equal(t, "Something is not working", issueType["description"])
assert.Equal(t, "d73a4a", issueType["color"])
}
func TestIssueView_json_ParentSubIssues(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseAllIssues2Fields()),
)
output, err := runCommand(httpReg, false, `123 --json parent,subIssues,subIssuesSummary`)
require.NoError(t, err)
var data map[string]interface{}
require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data))
// Parent
parent, ok := data["parent"].(map[string]interface{})
require.True(t, ok, "parent should be an object")
assert.Equal(t, float64(100), parent["number"])
assert.Equal(t, "Epic: Authentication overhaul", parent["title"])
assert.Equal(t, "https://github.com/OWNER/REPO/issues/100", parent["url"])
assert.Equal(t, "OPEN", parent["state"])
// Sub-issues
subIssues, ok := data["subIssues"].([]interface{})
require.True(t, ok, "subIssues should be an array")
require.Len(t, subIssues, 2)
sub0 := subIssues[0].(map[string]interface{})
assert.Equal(t, float64(101), sub0["number"])
assert.Equal(t, "Design auth module", sub0["title"])
assert.Equal(t, "CLOSED", sub0["state"])
sub1 := subIssues[1].(map[string]interface{})
assert.Equal(t, float64(102), sub1["number"])
assert.Equal(t, "Token refresh logic", sub1["title"])
assert.Equal(t, "OPEN", sub1["state"])
// Sub-issues summary
summary, ok := data["subIssuesSummary"].(map[string]interface{})
require.True(t, ok, "subIssuesSummary should be an object")
assert.Equal(t, float64(2), summary["total"])
assert.Equal(t, float64(1), summary["completed"])
assert.Equal(t, float64(50), summary["percentCompleted"])
}
func TestIssueView_json_BlockedByBlocking(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(issueResponseAllIssues2Fields()),
)
output, err := runCommand(httpReg, false, `123 --json blockedBy,blocking`)
require.NoError(t, err)
var data map[string]interface{}
require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data))
// Blocked by
blockedBy, ok := data["blockedBy"].([]interface{})
require.True(t, ok, "blockedBy should be an array")
require.Len(t, blockedBy, 1)
blocked0 := blockedBy[0].(map[string]interface{})
assert.Equal(t, float64(200), blocked0["number"])
assert.Equal(t, "API rate limiting", blocked0["title"])
assert.Equal(t, "https://github.com/OWNER/REPO/issues/200", blocked0["url"])
assert.Equal(t, "OPEN", blocked0["state"])
// Blocking
blocking, ok := data["blocking"].([]interface{})
require.True(t, ok, "blocking should be an array")
require.Len(t, blocking, 1)
blocking0 := blocking[0].(map[string]interface{})
assert.Equal(t, float64(300), blocking0["number"])
assert.Equal(t, "Release v2.0", blocking0["title"])
assert.Equal(t, "https://github.com/OWNER/REPO/issues/300", blocking0["url"])
assert.Equal(t, "OPEN", blocking0["state"])
}