Isolate issue commands

This commit is contained in:
Mislav Marohnić 2020-08-07 00:27:55 +02:00
parent 20e30453d3
commit c00fe73d5a
26 changed files with 2178 additions and 1664 deletions

View file

@ -0,0 +1,80 @@
package close
import (
"fmt"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/issue/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type CloseOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
SelectorArg string
}
func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command {
opts := &CloseOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "close {<number> | <url>}",
Short: "Close issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if runF != nil {
return runF(opts)
}
return closeRun(opts)
},
}
return cmd
}
func closeRun(opts *CloseOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
return err
}
if issue.Closed {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title)
return nil
}
err = api.IssueClose(apiClient, baseRepo, *issue)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title)
return nil
}

View file

@ -0,0 +1,119 @@
package close
import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
)
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdClose(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueClose(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue"}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := runCommand(http, true, "13")
if err != nil {
t.Fatalf("error running command `issue close`: %v", err)
}
r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueClose_alreadyClosed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
} } }
`))
output, err := runCommand(http, true, "13")
if err != nil {
t.Fatalf("error running command `issue close`: %v", err)
}
r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueClose_issuesDisabled(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
_, err := runCommand(http, true, "13")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Fatalf("got error: %v", err)
}
}

View file

@ -0,0 +1,228 @@
package create
import (
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type CreateOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
RepoOverride string
WebMode bool
Title string
TitleProvided bool
Body string
BodyProvided bool
Assignees []string
Labels []string
Projects []string
Milestone string
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "create",
Short: "Create a new issue",
Example: heredoc.Doc(`
$ gh issue create --title "I found a bug" --body "Nothing works"
$ gh issue create --label "bug,help wanted"
$ gh issue create --label bug --label "help wanted"
$ gh issue create --assignee monalisa,hubot
$ gh issue create --project "Roadmap"
`),
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TitleProvided = cmd.Flags().Changed("title")
opts.BodyProvided = cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
if runF != nil {
return runF(opts)
}
return createRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`")
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`")
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`")
return cmd
}
func createRun(opts *CreateOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
var nonLegacyTemplateFiles []string
if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
isTerminal := opts.IO.IsStdoutTTY()
var milestones []string
if opts.Milestone != "" {
milestones = []string{opts.Milestone}
}
if opts.WebMode {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
if opts.Title != "" || opts.Body != "" {
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones)
if err != nil {
return err
}
} else if len(nonLegacyTemplateFiles) > 1 {
openURL += "/choose"
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo))
}
repo, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
return err
}
if !repo.HasIssuesEnabled {
return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
}
action := prShared.SubmitAction
tb := prShared.IssueMetadataState{
Type: prShared.IssueMetadata,
Assignees: opts.Assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestones,
}
title := opts.Title
body := opts.Body
interactive := !(opts.TitleProvided && opts.BodyProvided)
if interactive && !isTerminal {
return fmt.Errorf("must provide --title and --body when not attached to a terminal")
}
if interactive {
var legacyTemplateFile *string
if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
action = tb.Action
if tb.Action == prShared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
} else {
if title == "" {
return fmt.Errorf("title can't be blank")
}
}
if action == prShared.PreviewAction {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
if err != nil {
return err
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
} else if action == prShared.SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
}
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
if err != nil {
return err
}
newIssue, err := api.IssueCreate(apiClient, repo, params)
if err != nil {
return err
}
fmt.Fprintln(opts.IO.Out, newIssue.URL)
} else {
panic("Unreachable state")
}
return nil
}

View file

@ -0,0 +1,280 @@
package create
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"os/exec"
"reflect"
"strings"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdCreate(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueCreate_nontty_error(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }
`))
_, err := runCommand(http, false, `-t hello`)
if err == nil {
t.Fatal("expected error running command `issue create`")
}
assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error())
}
func TestIssueCreate(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createIssue": { "issue": {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`))
output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "hello")
eq(t, reqBody.Variables.Input.Body, "cash rules everything around me")
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_metadata(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,
"viewerPermission": "WRITE"
} } }
`))
http.Register(
httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
httpmock.StringResponse(`
{ "data": {
"u000": { "login": "MonaLisa", "id": "MONAID" },
"repository": {
"l000": { "name": "bug", "id": "BUGID" },
"l001": { "name": "TODO", "id": "TODOID" }
}
} }
`))
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(`mutation IssueCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createIssue": { "issue": {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["title"], "TITLE")
eq(t, inputs["body"], "BODY")
eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
eq(t, inputs["milestoneId"], "BIGONEID")
if v, ok := inputs["userIds"]; ok {
t.Errorf("did not expect userIds: %v", v)
}
if v, ok := inputs["teamIds"]; ok {
t.Errorf("did not expect teamIds: %v", v)
}
}))
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": false
} } }
`))
_, err := runCommand(http, true, `-t heres -b johnny`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue create`: %v", err)
}
}
func TestIssueCreate_web(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, `--web`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/OWNER/REPO/issues/new")
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
}
func TestIssueCreate_webTitleBody(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, `-w -t mytitle -b mybody`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle")
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
}

54
pkg/cmd/issue/issue.go Normal file
View file

@ -0,0 +1,54 @@
package issue
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
cmdClose "github.com/cli/cli/pkg/cmd/issue/close"
cmdCreate "github.com/cli/cli/pkg/cmd/issue/create"
cmdList "github.com/cli/cli/pkg/cmd/issue/list"
cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen"
cmdStatus "github.com/cli/cli/pkg/cmd/issue/status"
cmdView "github.com/cli/cli/pkg/cmd/issue/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "issue <command>",
Short: "Manage issues",
Long: `Work with GitHub issues`,
Example: heredoc.Doc(`
$ gh issue list
$ gh issue create --label bug
$ gh issue view --web
`),
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": heredoc.Doc(`
An issue can be supplied as argument in any of the following formats:
- by number, e.g. "123"; or
- by URL, e.g. "https://github.com/OWNER/REPO/issues/123".
`),
},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if repo, _ := cmd.Flags().GetString("repo"); repo != "" {
// NOTE: this mutates the factory
f.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName(repo)
}
}
},
}
cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format")
cmd.AddCommand(cmdClose.NewCmdClose(f, nil))
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil))
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
return cmd
}

View file

@ -0,0 +1,51 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issues": {
"totalCount": 3,
"nodes": [
{
"number": 1,
"title": "number won",
"url": "https://wow.com",
"labels": {
"nodes": [
{
"name": "label"
}
],
"totalCount": 1
}
},
{
"number": 2,
"title": "number too",
"url": "https://wow.com",
"labels": {
"nodes": [
{
"name": "label"
}
],
"totalCount": 1
}
},
{
"number": 4,
"title": "number fore",
"url": "https://wow.com",
"labels": {
"nodes": [
{
"name": "label"
}
],
"totalCount": 1
}
}
]
}
}
}
}

127
pkg/cmd/issue/list/list.go Normal file
View file

@ -0,0 +1,127 @@
package list
import (
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ListOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
WebMode bool
Assignee string
Labels []string
State string
LimitResults int
Author string
Mention string
Milestone string
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "list",
Short: "List and filter issues in this repository",
Example: heredoc.Doc(`
$ gh issue list -l "help wanted"
$ gh issue list -A monalisa
$ gh issue list --web
`),
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if opts.LimitResults < 1 {
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.LimitResults)}
}
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the issue(s)")
cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee")
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels")
cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|all}")
cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of issues to fetch")
cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author")
cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention")
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone `name`")
return cmd
}
func listRun(opts *ListOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
if opts.WebMode {
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{
Entity: "issue",
State: opts.State,
Assignee: opts.Assignee,
Labels: opts.Labels,
Author: opts.Author,
Mention: opts.Mention,
Milestone: opts.Milestone,
})
if err != nil {
return err
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}
listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, opts.Assignee, opts.LimitResults, opts.Author, opts.Mention, opts.Milestone)
if err != nil {
return err
}
if isTerminal {
hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != ""
title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title)
}
issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues)
return nil
}

View file

@ -0,0 +1,223 @@
package list
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"os/exec"
"reflect"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdList(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueList_nontty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.FileResponse("./fixtures/issueList.json"))
output, err := runCommand(http, false, "")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(),
`1[\t]+number won[\t]+label[\t]+\d+`,
`2[\t]+number too[\t]+label[\t]+\d+`,
`4[\t]+number fore[\t]+label[\t]+\d+`)
}
func TestIssueList_tty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.FileResponse("./fixtures/issueList.json"))
output, err := runCommand(http, true, "")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.Stderr(), `
Showing 3 of 3 open issues in OWNER/REPO
`)
test.ExpectLines(t, output.String(),
"number won",
"number too",
"number fore")
}
func TestIssueList_tty_withFlags(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, "probablyCher", params["assignee"].(string))
assert.Equal(t, "foo", params["author"].(string))
assert.Equal(t, "me", params["mention"].(string))
assert.Equal(t, "1.x", params["milestone"].(string))
assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{}))
assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{}))
}))
output, err := runCommand(http, true, "-a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), `
No issues match your search in OWNER/REPO
`)
}
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
_, err := runCommand(http, true, "--limit=0")
if err == nil || err.Error() != "invalid limit: 0" {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_nullAssigneeLabels(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }
`))
_, err := runCommand(http, true, "")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables map[string]interface{}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
_, assigneeDeclared := reqBody.Variables["assignee"]
_, labelsDeclared := reqBody.Variables["labels"]
eq(t, assigneeDeclared, false)
eq(t, labelsDeclared, false)
}
func TestIssueList_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
_, err := runCommand(http, true, "")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_web(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, "--web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1")
if err != nil {
t.Errorf("error running command `issue list` with `--web` flag: %v", err)
}
expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1"
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, expectedURL)
}

View file

@ -0,0 +1,80 @@
package reopen
import (
"fmt"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/issue/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ReopenOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
SelectorArg string
}
func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command {
opts := &ReopenOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "reopen {<number> | <url>}",
Short: "Reopen issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if runF != nil {
return runF(opts)
}
return reopenRun(opts)
},
}
return cmd
}
func reopenRun(opts *ReopenOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
return err
}
if !issue.Closed {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title)
return nil
}
err = api.IssueReopen(apiClient, baseRepo, *issue)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title)
return nil
}

View file

@ -0,0 +1,119 @@
package reopen
import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
)
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdReopen(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueReopen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": true, "title": "The title of the issue"}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := runCommand(http, true, "2")
if err != nil {
t.Fatalf("error running command `issue reopen`: %v", err)
}
r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueReopen_alreadyOpen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": false, "title": "The title of the issue"}
} } }
`))
output, err := runCommand(http, true, "2")
if err != nil {
t.Fatalf("error running command `issue reopen`: %v", err)
}
r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueReopen_issuesDisabled(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
_, err := runCommand(http, true, "2")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Fatalf("got error: %v", err)
}
}

View file

@ -0,0 +1,65 @@
package shared
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/cli/cli/api"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
)
func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues []api.Issue) {
table := utils.NewTablePrinter(io)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
if table.IsTTY() {
issueNum = "#" + issueNum
}
issueNum = prefix + issueNum
labels := IssueLabelList(issue)
if labels != "" && table.IsTTY() {
labels = fmt.Sprintf("(%s)", labels)
}
now := time.Now()
ago := now.Sub(issue.UpdatedAt)
table.AddField(issueNum, nil, prShared.ColorFuncForState(issue.State))
if !table.IsTTY() {
table.AddField(issue.State, nil, nil)
}
table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil)
table.AddField(labels, nil, utils.Gray)
if table.IsTTY() {
table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)
} else {
table.AddField(issue.UpdatedAt.String(), nil, nil)
}
table.EndRow()
}
_ = table.Render()
remaining := totalCount - len(issues)
if remaining > 0 {
fmt.Fprintf(io.Out, utils.Gray("%sAnd %d more\n"), prefix, remaining)
}
}
func IssueLabelList(issue api.Issue) string {
if len(issue.Labels.Nodes) == 0 {
return ""
}
labelNames := make([]string, 0, len(issue.Labels.Nodes))
for _, label := range issue.Labels.Nodes {
labelNames = append(labelNames, label.Name)
}
list := strings.Join(labelNames, ", ")
if issue.Labels.TotalCount > len(issue.Labels.Nodes) {
list += ", …"
}
return list
}

View file

@ -0,0 +1,62 @@
package shared
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
)
func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) {
issue, baseRepo, err := issueFromURL(apiClient, arg)
if err != nil {
return nil, nil, err
}
if issue != nil {
return issue, baseRepo, nil
}
baseRepo, err = baseRepoFn()
if err != nil {
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
}
issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#"))
if err != nil {
return nil, nil, fmt.Errorf("invalid issue format: %q", arg)
}
issue, err = issueFromNumber(apiClient, baseRepo, issueNumber)
return issue, baseRepo, err
}
var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`)
func issueFromURL(apiClient *api.Client, s string) (*api.Issue, ghrepo.Interface, error) {
u, err := url.Parse(s)
if err != nil {
return nil, nil, nil
}
if u.Scheme != "https" && u.Scheme != "http" {
return nil, nil, nil
}
m := issueURLRE.FindStringSubmatch(u.Path)
if m == nil {
return nil, nil, nil
}
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
issueNumber, _ := strconv.Atoi(m[3])
issue, err := issueFromNumber(apiClient, repo, issueNumber)
return issue, repo, err
}
func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber int) (*api.Issue, error) {
return api.IssueByNumber(apiClient, repo, issueNumber)
}

View file

@ -0,0 +1,37 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"assigned": {
"totalCount": 2,
"nodes": [
{
"number": 9,
"title": "corey thinks squash tastes bad"
},
{
"number": 10,
"title": "broccoli is a superfood"
}
]
},
"mentioned": {
"totalCount": 2,
"nodes": [
{
"number": 8,
"title": "rabbits eat carrots"
},
{
"number": 11,
"title": "swiss chard is neutral"
}
]
},
"authored": {
"totalCount": 0,
"nodes": []
}
}
}
}

View file

@ -0,0 +1,103 @@
package status
import (
"fmt"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type StatusOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
}
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if runF != nil {
return runF(opts)
}
return statusRun(opts)
},
}
return cmd
}
func statusRun(opts *StatusOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost())
if err != nil {
return err
}
issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser)
if err != nil {
return err
}
out := opts.IO.Out
fmt.Fprintln(out, "")
fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo))
fmt.Fprintln(out, "")
prShared.PrintHeader(out, "Issues assigned to you")
if issuePayload.Assigned.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
} else {
message := " There are no issues assigned to you"
prShared.PrintMessage(out, message)
}
fmt.Fprintln(out)
prShared.PrintHeader(out, "Issues mentioning you")
if issuePayload.Mentioned.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues)
} else {
prShared.PrintMessage(out, " There are no issues mentioning you")
}
fmt.Fprintln(out)
prShared.PrintHeader(out, "Issues opened by you")
if issuePayload.Authored.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues)
} else {
prShared.PrintMessage(out, " There are no issues opened by you")
}
fmt.Fprintln(out)
return nil
}

View file

@ -0,0 +1,146 @@
package status
import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
)
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdStatus(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueStatus(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
http.Register(
httpmock.GraphQL(`query IssueStatus\b`),
httpmock.FileResponse("./fixtures/issueStatus.json"))
output, err := runCommand(http, true, "")
if err != nil {
t.Errorf("error running command `issue status`: %v", err)
}
expectedIssues := []*regexp.Regexp{
regexp.MustCompile(`(?m)8.*carrots.*about.*ago`),
regexp.MustCompile(`(?m)9.*squash.*about.*ago`),
regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`),
regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`),
}
for _, r := range expectedIssues {
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
}
func TestIssueStatus_blankSlate(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
http.Register(
httpmock.GraphQL(`query IssueStatus\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"assigned": { "nodes": [] },
"mentioned": { "nodes": [] },
"authored": { "nodes": [] }
} } }`))
output, err := runCommand(http, true, "")
if err != nil {
t.Errorf("error running command `issue status`: %v", err)
}
expectedOutput := `
Relevant issues in OWNER/REPO
Issues assigned to you
There are no issues assigned to you
Issues mentioning you
There are no issues mentioning you
Issues opened by you
There are no issues opened by you
`
if output.String() != expectedOutput {
t.Errorf("expected %q, got %q", expectedOutput, output)
}
}
func TestIssueStatus_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
http.Register(
httpmock.GraphQL(`query IssueStatus\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }`))
_, err := runCommand(http, true, "")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue status`: %v", err)
}
}

View file

@ -0,0 +1,36 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "**bold story**",
"title": "ix of coins",
"state": "OPEN",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"assignees": {
"nodes": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {
"title": ""
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,28 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "**bold story**",
"title": "ix of coins",
"state": "CLOSED",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{
"name": "tarot"
}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,28 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "",
"title": "ix of coins",
"state": "OPEN",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{
"name": "tarot"
}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,92 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "**bold story**",
"title": "ix of coins",
"state": "OPEN",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"assignees": {
"nodes": [
{
"login": "marseilles"
},
{
"login": "monaco"
}
],
"totalcount": 2
},
"labels": {
"nodes": [
{
"name": "one"
},
{
"name": "two"
},
{
"name": "three"
},
{
"name": "four"
},
{
"name": "five"
}
],
"totalcount": 5
},
"projectcards": {
"nodes": [
{
"project": {
"name": "Project 1"
},
"column": {
"name": "column A"
}
},
{
"project": {
"name": "Project 2"
},
"column": {
"name": "column B"
}
},
{
"project": {
"name": "Project 3"
},
"column": {
"name": "column C"
}
},
{
"project": {
"name": "Project 4"
},
"column": {
"name": ""
}
}
],
"totalcount": 3
},
"milestone": {
"title": "uluru"
},
"comments": {
"totalcount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

207
pkg/cmd/issue/view/view.go Normal file
View file

@ -0,0 +1,207 @@
package view
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/issue/shared"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ViewOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
SelectorArg string
WebMode bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "view {<number> | <url>}",
Short: "View an issue",
Long: heredoc.Doc(`
Display the title, body, and other information about an issue.
With '--web', open the issue in a web browser instead.
`),
Example: heredoc.Doc(`
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser")
return cmd
}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
return err
}
openURL := issue.URL
if opts.WebMode {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
}
if opts.IO.IsStdoutTTY() {
return printHumanIssuePreview(opts.IO.Out, issue)
}
return printRawIssuePreview(opts.IO.Out, issue)
}
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
assignees := issueAssigneeList(*issue)
labels := shared.IssueLabelList(*issue)
projects := issueProjectList(*issue)
// Print empty strings for empty values so the number of metadata lines is consistent when
// processing many issues with head and grep.
fmt.Fprintf(out, "title:\t%s\n", issue.Title)
fmt.Fprintf(out, "state:\t%s\n", issue.State)
fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
fmt.Fprintf(out, "labels:\t%s\n", labels)
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
fmt.Fprintf(out, "projects:\t%s\n", projects)
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
return nil
}
func printHumanIssuePreview(out io.Writer, issue *api.Issue) error {
now := time.Now()
ago := now.Sub(issue.CreatedAt)
// Header (Title and State)
fmt.Fprintln(out, utils.Bold(issue.Title))
fmt.Fprint(out, issueStateTitleWithColor(issue.State))
fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
" • %s opened %s • %s",
issue.Author.Login,
utils.FuzzyAgo(ago),
utils.Pluralize(issue.Comments.TotalCount, "comment"),
)))
// Metadata
fmt.Fprintln(out)
if assignees := issueAssigneeList(*issue); assignees != "" {
fmt.Fprint(out, utils.Bold("Assignees: "))
fmt.Fprintln(out, assignees)
}
if labels := shared.IssueLabelList(*issue); labels != "" {
fmt.Fprint(out, utils.Bold("Labels: "))
fmt.Fprintln(out, labels)
}
if projects := issueProjectList(*issue); projects != "" {
fmt.Fprint(out, utils.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if issue.Milestone.Title != "" {
fmt.Fprint(out, utils.Bold("Milestone: "))
fmt.Fprintln(out, issue.Milestone.Title)
}
// Body
if issue.Body != "" {
fmt.Fprintln(out)
md, err := utils.RenderMarkdown(issue.Body)
if err != nil {
return err
}
fmt.Fprintln(out, md)
}
fmt.Fprintln(out)
// Footer
fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL)
return nil
}
func issueStateTitleWithColor(state string) string {
colorFunc := prShared.ColorFuncForState(state)
return colorFunc(strings.Title(strings.ToLower(state)))
}
func issueAssigneeList(issue api.Issue) string {
if len(issue.Assignees.Nodes) == 0 {
return ""
}
AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
for _, assignee := range issue.Assignees.Nodes {
AssigneeNames = append(AssigneeNames, assignee.Login)
}
list := strings.Join(AssigneeNames, ", ")
if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) {
list += ", …"
}
return list
}
func issueProjectList(issue api.Issue) string {
if len(issue.ProjectCards.Nodes) == 0 {
return ""
}
projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
for _, project := range issue.ProjectCards.Nodes {
colName := project.Column.Name
if colName == "" {
colName = "Awaiting triage"
}
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
}
list := strings.Join(projectNames, ", ")
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
list += ", …"
}
return list
}

View file

@ -0,0 +1,340 @@
package view
import (
"bytes"
"io/ioutil"
"net/http"
"os/exec"
"reflect"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
factory := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdView(factory, nil)
argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestIssueView_web(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, "-w 123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_web_numberArgWithHash(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, "-w \"#123\"")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_nontty_Preview(t *testing.T) {
tests := map[string]struct {
fixture string
expectedOutputs []string
}{
"Open issue without metadata": {
fixture: "./fixtures/issueView_preview.json",
expectedOutputs: []string{
`title:\tix of coins`,
`state:\tOPEN`,
`comments:\t9`,
`author:\tmarseilles`,
`assignees:`,
`\*\*bold story\*\*`,
},
},
"Open issue with metadata": {
fixture: "./fixtures/issueView_previewWithMetadata.json",
expectedOutputs: []string{
`title:\tix of coins`,
`assignees:\tmarseilles, monaco`,
`author:\tmarseilles`,
`state:\tOPEN`,
`comments:\t9`,
`labels:\tone, two, three, four, five`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
`milestone:\tuluru\n`,
`\*\*bold story\*\*`,
},
},
"Open issue with empty body": {
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
expectedOutputs: []string{
`title:\tix of coins`,
`state:\tOPEN`,
`author:\tmarseilles`,
`labels:\ttarot`,
},
},
"Closed issue": {
fixture: "./fixtures/issueView_previewClosedState.json",
expectedOutputs: []string{
`title:\tix of coins`,
`state:\tCLOSED`,
`\*\*bold story\*\*`,
`author:\tmarseilles`,
`labels:\ttarot`,
`\*\*bold story\*\*`,
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
output, err := runCommand(http, false, "123")
if err != nil {
t.Errorf("error running `issue view`: %v", err)
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
}
func TestIssueView_tty_Preview(t *testing.T) {
tests := map[string]struct {
fixture string
expectedOutputs []string
}{
"Open issue without metadata": {
fixture: "./fixtures/issueView_preview.json",
expectedOutputs: []string{
`ix of coins`,
`Open.*marseilles opened about 292 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
"Open issue with metadata": {
fixture: "./fixtures/issueView_previewWithMetadata.json",
expectedOutputs: []string{
`ix of coins`,
`Open.*marseilles opened about 292 years ago.*9 comments`,
`Assignees:.*marseilles, monaco\n`,
`Labels:.*one, two, three, four, five\n`,
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
`Milestone:.*uluru\n`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
"Open issue with empty body": {
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
expectedOutputs: []string{
`ix of coins`,
`Open.*marseilles opened about 292 years ago.*9 comments`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
"Closed issue": {
fixture: "./fixtures/issueView_previewClosedState.json",
expectedOutputs: []string{
`ix of coins`,
`Closed.*marseilles opened about 292 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
output, err := runCommand(http, true, "123")
if err != nil {
t.Errorf("error running `issue view`: %v", err)
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
}
func TestIssueView_web_notFound(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "errors": [
{ "message": "Could not resolve to an Issue with the number of 9999." }
] }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
_, err := runCommand(http, true, "-w 9999")
if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." {
t.Errorf("error running command `issue view`: %v", err)
}
if seenCmd != nil {
t.Fatal("did not expect any command to run")
}
}
func TestIssueView_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": false
} } }
`))
_, err := runCommand(http, true, `6666`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue view`: %v", err)
}
}
func TestIssueView_web_urlArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, "-w https://github.com/OWNER/REPO/issues/123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.String(), "")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
}

View file

@ -77,7 +77,7 @@ func closeRun(opts *CloseOptions) error {
return fmt.Errorf("API call failed: %w", err)
}
fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title)
fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title)
return nil
}

View file

@ -1,6 +1,7 @@
package pr
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout"
cmdClose "github.com/cli/cli/pkg/cmd/pr/close"
@ -13,8 +14,6 @@ import (
cmdReview "github.com/cli/cli/pkg/cmd/pr/review"
cmdStatus "github.com/cli/cli/pkg/cmd/pr/status"
cmdView "github.com/cli/cli/pkg/cmd/pr/view"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)