From 357de1b18349b1926a9564626bdf01686af712ed Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 30 Oct 2019 16:26:33 -0700 Subject: [PATCH 1/6] Add Issue query --- api/queries.go | 103 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/api/queries.go b/api/queries.go index e1e078138..c0bc53eca 100644 --- a/api/queries.go +++ b/api/queries.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "time" ) type PullRequestsPayload struct { @@ -22,6 +23,108 @@ type Repo interface { RepoOwner() string } +type IssuesPayload struct { + Assigned []Issue + Mentioned []Issue + Recent []Issue +} + +type Issue struct { + Number int + Title string +} + +func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { + type issues struct { + Issues struct { + Edges []struct { + Node Issue + } + } + } + + type response struct { + Assigned issues + Mentioned issues + Recent issues + } + + query := ` + fragment issue on Issue { + number + title + } + query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) { + assigned: repository(owner: $owner, name: $repo) { + issues(filterBy: {assignee: $viewer}, first: $per_page) { + edges { + node { + ...issue + } + } + } + } + mentioned: repository(owner: $owner, name: $repo) { + issues(filterBy: {mentioned: $viewer}, first: $per_page) { + edges { + node { + ...issue + } + } + } + } + recent: repository(owner: $owner, name: $repo) { + issues(filterBy: {since: $since, orderBy: {field: CREATED_AT, direction: DESC}}, first: $per_page) { + edges { + node { + ...issue + } + } + } + } + } + ` + + owner := ghRepo.RepoOwner() + repo := ghRepo.RepoName() + since := time.Now().UTC().Add(time.Hour * -24).Format("2006-01-02T15:04:05-0700") + variables := map[string]interface{}{ + "owner": owner, + "repo": repo, + "viewer": currentUsername, + "since": since, + } + + var resp response + err := client.GraphQL(query, variables, &resp) + if err != nil { + return nil, err + } + + var assigned []Issue + for _, edge := range resp.Assigned.Issues.Edges { + assigned = append(assigned, edge.Node) + } + + var mentioned []Issue + for _, edge := range resp.Mentioned.Issues.Edges { + mentioned = append(mentioned, edge.Node) + } + + var recent []Issue + for _, edge := range resp.Recent.Issues.Edges { + recent = append(recent, edge.Node) + } + + payload := IssuesPayload{ + assigned, + mentioned, + recent, + } + + return &payload, nil +} + func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername string) (*PullRequestsPayload, error) { type edges struct { Edges []struct { From cf1feb847e65c17dacf99bc5b0340e88a97c59b4 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Thu, 31 Oct 2019 11:02:27 -0700 Subject: [PATCH 2/6] Add `gh issue list` and `gh issue view ISSUE_NUMBER` --- command/issue.go | 117 +++++++++++++++++++++++++++++++++++ command/issue_test.go | 61 ++++++++++++++++++ command/pr.go | 6 +- command/pr_test.go | 35 ----------- command/testing.go | 39 ++++++++++++ test/fixtures/issueList.json | 47 ++++++++++++++ test/fixtures/issueView.json | 36 +++++++++++ test/helpers.go | 1 + 8 files changed, 303 insertions(+), 39 deletions(-) create mode 100644 command/issue.go create mode 100644 command/issue_test.go create mode 100644 command/testing.go create mode 100644 test/fixtures/issueList.json create mode 100644 test/fixtures/issueView.json diff --git a/command/issue.go b/command/issue.go new file mode 100644 index 000000000..e64d8d806 --- /dev/null +++ b/command/issue.go @@ -0,0 +1,117 @@ +package command + +import ( + "fmt" + "strconv" + + "github.com/github/gh-cli/api" + "github.com/github/gh-cli/utils" + "github.com/spf13/cobra" +) + +func init() { + RootCmd.AddCommand(issueCmd) + issueCmd.AddCommand( + &cobra.Command{ + Use: "list", + Short: "List issues", + RunE: issueList, + }, + &cobra.Command{ + Use: "view issue-number", + Args: cobra.MinimumNArgs(1), + Short: "Open a issue in the browser", + RunE: issueView, + }, + ) +} + +var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Work with GitHub issues", + Long: `This command allows you to work with issues.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("%+v is not a valid issue command", args) + }, +} + +func issueList(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ctx := contextForCommand(cmd) + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + baseRepo, err := ctx.BaseRepo() + if err != nil { + return err + } + + currentUser, err := ctx.AuthLogin() + if err != nil { + return err + } + + issuePayload, err := api.Issues(apiClient, baseRepo, currentUser) + if err != nil { + return err + } + + printHeader("Issues assigned to you") + if issuePayload.Assigned != nil { + printIssues(issuePayload.Assigned...) + } else { + message := fmt.Sprintf(" There are no issues assgined to you") + printMessage(message) + } + fmt.Println() + + printHeader("Issues mentioning you") + if len(issuePayload.Mentioned) > 0 { + printIssues(issuePayload.Mentioned...) + } else { + printMessage(" There are no issues mentioning you") + } + fmt.Println() + + printHeader("Recent issues") + if len(issuePayload.Recent) > 0 { + printIssues(issuePayload.Recent...) + } else { + printMessage(" There are no recent issues") + } + fmt.Println() + + return nil +} + +func issueView(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ctx := contextForCommand(cmd) + + baseRepo, err := ctx.BaseRepo() + if err != nil { + return err + } + + var openURL string + if number, err := strconv.Atoi(args[0]); err == nil { + // TODO: move URL generation into GitHubRepository + openURL = fmt.Sprintf("https://github.com/%s/%s/issues/%d", baseRepo.RepoOwner(), baseRepo.RepoName(), number) + } else { + return fmt.Errorf("invalid issue number: '%s'", args[0]) + } + + fmt.Printf("Opening %s in your browser.\n", openURL) + return utils.OpenInBrowser(openURL) +} + +func printIssues(issues ...api.Issue) { + for _, issue := range issues { + fmt.Printf(" #%d %s\n", issue.Number, truncateTitle(issue.Title, 70)) + } +} diff --git a/command/issue_test.go b/command/issue_test.go new file mode 100644 index 000000000..69312cac7 --- /dev/null +++ b/command/issue_test.go @@ -0,0 +1,61 @@ +package command + +import ( + "os" + "regexp" + "testing" + + "github.com/github/gh-cli/test" +) + +func TestIssueList(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + jsonFile, _ := os.Open("../test/fixtures/issueList.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := test.RunCommand(RootCmd, "issue list") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + expectedIssues := []*regexp.Regexp{ + regexp.MustCompile(`#8.*carrots`), + regexp.MustCompile(`#9.*squash`), + regexp.MustCompile(`#10.*broccoli`), + regexp.MustCompile(`#11.*swiss chard`), + } + + for _, r := range expectedIssues { + if !r.MatchString(output) { + t.Errorf("output did not match regexp /%s/", r) + } + } +} + +func TestIssueView(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + jsonFile, _ := os.Open("../test/fixtures/issueView.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + teardown, callCount := mockOpenInBrowser() + defer teardown() + + output, err := test.RunCommand(RootCmd, "issue view 8") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + if output == "" { + t.Errorf("command output expected got an empty string") + } + + if *callCount != 1 { + t.Errorf("OpenInBrowser should be called 1 time but was called %d time(s)", *callCount) + } +} diff --git a/command/pr.go b/command/pr.go index f1dfe8fb0..182522f29 100644 --- a/command/pr.go +++ b/command/pr.go @@ -129,7 +129,7 @@ func prView(cmd *cobra.Command, args []string) error { func printPrs(prs ...api.PullRequest) { for _, pr := range prs { - fmt.Printf(" #%d %s %s\n", pr.Number, truncateTitle(pr.Title), utils.Cyan("["+pr.HeadRefName+"]")) + fmt.Printf(" #%d %s %s\n", pr.Number, truncateTitle(pr.Title, 50), utils.Cyan("["+pr.HeadRefName+"]")) } } @@ -141,9 +141,7 @@ func printMessage(s string) { fmt.Println(utils.Gray(s)) } -func truncateTitle(title string) string { - const maxLength = 50 - +func truncateTitle(title string, maxLength int) string { if len(title) > maxLength { return title[0:maxLength-3] + "..." } diff --git a/command/pr_test.go b/command/pr_test.go index e4a5122d7..5ac21b9bd 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -5,29 +5,9 @@ import ( "regexp" "testing" - "github.com/github/gh-cli/api" - "github.com/github/gh-cli/context" "github.com/github/gh-cli/test" - "github.com/github/gh-cli/utils" ) -func initBlankContext(repo, branch string) { - initContext = func() context.Context { - ctx := context.NewBlank() - ctx.SetBaseRepo(repo) - ctx.SetBranch(branch) - return ctx - } -} - -func initFakeHTTP() *api.FakeHTTP { - http := &api.FakeHTTP{} - apiClientForContext = func(context.Context) (*api.Client, error) { - return api.NewClient(api.ReplaceTripper(http)), nil - } - return http -} - func TestPRList(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() @@ -114,18 +94,3 @@ func TestPRView_NoActiveBranch(t *testing.T) { t.Errorf("OpenInBrowser should be called once but was called %d time(s)", *callCount) } } - -func mockOpenInBrowser() (func(), *int) { - callCount := 0 - originalOpenInBrowser := utils.OpenInBrowser - teardown := func() { - utils.OpenInBrowser = originalOpenInBrowser - } - - utils.OpenInBrowser = func(_ string) error { - callCount++ - return nil - } - - return teardown, &callCount -} diff --git a/command/testing.go b/command/testing.go new file mode 100644 index 000000000..a6ccf4807 --- /dev/null +++ b/command/testing.go @@ -0,0 +1,39 @@ +package command + +import ( + "github.com/github/gh-cli/api" + "github.com/github/gh-cli/context" + "github.com/github/gh-cli/utils" +) + +func initBlankContext(repo, branch string) { + initContext = func() context.Context { + ctx := context.NewBlank() + ctx.SetBaseRepo(repo) + ctx.SetBranch(branch) + return ctx + } +} + +func initFakeHTTP() *api.FakeHTTP { + http := &api.FakeHTTP{} + apiClientForContext = func(context.Context) (*api.Client, error) { + return api.NewClient(api.ReplaceTripper(http)), nil + } + return http +} + +func mockOpenInBrowser() (func(), *int) { + callCount := 0 + originalOpenInBrowser := utils.OpenInBrowser + teardown := func() { + utils.OpenInBrowser = originalOpenInBrowser + } + + utils.OpenInBrowser = func(_ string) error { + callCount++ + return nil + } + + return teardown, &callCount +} diff --git a/test/fixtures/issueList.json b/test/fixtures/issueList.json new file mode 100644 index 000000000..784c0cc8f --- /dev/null +++ b/test/fixtures/issueList.json @@ -0,0 +1,47 @@ +{ + "data": { + "assigned": { + "issues": { + "edges": [ + { + "node": { + "number": 9, + "title": "corey thinks squash tastes bad" + } + }, + { + "node": { + "number": 10, + "title": "broccoli is a superfood" + } + } + ] + } + }, + "mentioned": { + "issues": { + "edges": [ + { + "node": { + "number": 8, + "title": "rabbits eat carrots" + } + }, + { + "node": { + "number": 11, + "title": "swiss chard is neutral" + } + } + ] + } + }, + "recent": { + "issues": { + "edges": [] + } + }, + + "pageInfo": { "hasNextPage": false } + } +} diff --git a/test/fixtures/issueView.json b/test/fixtures/issueView.json new file mode 100644 index 000000000..c9b0cb1b6 --- /dev/null +++ b/test/fixtures/issueView.json @@ -0,0 +1,36 @@ +{ + "data": { + "repository": { + "issues": { + "edges": [ + { + "node": { + "number": 8, + "title": "rabbits eat carrots", + "url": "https://github.com/github/gh-cli/pull/10" + } + }, + { + "node": { + "number": 9, + "title": "corey thinks squash tastes bad" + } + }, + { + "node": { + "number": 10, + "title": "broccoli is a superfood" + } + }, + { + "node": { + "number": 11, + "title": "swiss chard is neutral" + } + } + ] + } + }, + "pageInfo": { "hasNextPage": false } + } +} diff --git a/test/helpers.go b/test/helpers.go index d9276565c..162befa56 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -71,6 +71,7 @@ func RunCommand(root *cobra.Command, s string) (string, error) { root.SetArgs(strings.Split(s, " ")) _, err = root.ExecuteC() }) + if err != nil { return "", err } From 875352a03cd1c7afc4cf717e5c74bf46e367eafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 1 Nov 2019 14:34:33 +0100 Subject: [PATCH 3/6] Fix issues order --- api/queries.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/queries.go b/api/queries.go index c0bc53eca..161aa9e59 100644 --- a/api/queries.go +++ b/api/queries.go @@ -56,7 +56,7 @@ func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload } query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) { assigned: repository(owner: $owner, name: $repo) { - issues(filterBy: {assignee: $viewer}, first: $per_page) { + issues(filterBy: {assignee: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue @@ -65,7 +65,7 @@ func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload } } mentioned: repository(owner: $owner, name: $repo) { - issues(filterBy: {mentioned: $viewer}, first: $per_page) { + issues(filterBy: {mentioned: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue @@ -74,7 +74,7 @@ func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload } } recent: repository(owner: $owner, name: $repo) { - issues(filterBy: {since: $since, orderBy: {field: CREATED_AT, direction: DESC}}, first: $per_page) { + issues(filterBy: {since: $since}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue From b50898f05b97d36292281172035ef9ea575522db Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 6 Nov 2019 10:01:16 -0800 Subject: [PATCH 4/6] move to init --- command/issue.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/command/issue.go b/command/issue.go index e64d8d806..17dc9507e 100644 --- a/command/issue.go +++ b/command/issue.go @@ -10,11 +10,20 @@ import ( ) func init() { - RootCmd.AddCommand(issueCmd) + var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Work with GitHub issues", + Long: `This command allows you to work with issues.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("%+v is not a valid issue command", args) + }, + } + issueCmd.AddCommand( &cobra.Command{ - Use: "list", - Short: "List issues", + Use: "status", + Short: "Display issue status", RunE: issueList, }, &cobra.Command{ @@ -24,16 +33,8 @@ func init() { RunE: issueView, }, ) -} -var issueCmd = &cobra.Command{ - Use: "issue", - Short: "Work with GitHub issues", - Long: `This command allows you to work with issues.`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("%+v is not a valid issue command", args) - }, + RootCmd.AddCommand(issueCmd) } func issueList(cmd *cobra.Command, args []string) error { From 738562436fdf602059118fea878ecc3f6541fa73 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 6 Nov 2019 10:02:35 -0800 Subject: [PATCH 5/6] Update tests --- command/issue_test.go | 8 ++++---- test/fixtures/{issueList.json => issueStatus.json} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename test/fixtures/{issueList.json => issueStatus.json} (100%) diff --git a/command/issue_test.go b/command/issue_test.go index 69312cac7..2181fcf37 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -8,17 +8,17 @@ import ( "github.com/github/gh-cli/test" ) -func TestIssueList(t *testing.T) { +func TestIssueStatus(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() - jsonFile, _ := os.Open("../test/fixtures/issueList.json") + jsonFile, _ := os.Open("../test/fixtures/issueStatus.json") defer jsonFile.Close() http.StubResponse(200, jsonFile) - output, err := test.RunCommand(RootCmd, "issue list") + output, err := test.RunCommand(RootCmd, "issue status") if err != nil { - t.Errorf("error running command `issue list`: %v", err) + t.Errorf("error running command `issue status`: %v", err) } expectedIssues := []*regexp.Regexp{ diff --git a/test/fixtures/issueList.json b/test/fixtures/issueStatus.json similarity index 100% rename from test/fixtures/issueList.json rename to test/fixtures/issueStatus.json From 3782ed6c8d43c332bdfa19d7ae30714478a6d245 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 6 Nov 2019 10:07:24 -0800 Subject: [PATCH 6/6] Tweaks --- command/issue.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/command/issue.go b/command/issue.go index 17dc9507e..973163f36 100644 --- a/command/issue.go +++ b/command/issue.go @@ -27,7 +27,7 @@ func init() { RunE: issueList, }, &cobra.Command{ - Use: "view issue-number", + Use: "view [issue-number]", Args: cobra.MinimumNArgs(1), Short: "Open a issue in the browser", RunE: issueView, @@ -38,8 +38,6 @@ func init() { } func issueList(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - ctx := contextForCommand(cmd) apiClient, err := apiClientForContext(ctx) if err != nil { @@ -90,8 +88,6 @@ func issueList(cmd *cobra.Command, args []string) error { } func issueView(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - ctx := contextForCommand(cmd) baseRepo, err := ctx.BaseRepo()