diff --git a/api/queries.go b/api/queries.go index e1e078138..161aa9e59 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, orderBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + ...issue + } + } + } + } + mentioned: repository(owner: $owner, name: $repo) { + issues(filterBy: {mentioned: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + ...issue + } + } + } + } + recent: repository(owner: $owner, name: $repo) { + issues(filterBy: {since: $since}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + 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 { diff --git a/command/issue.go b/command/issue.go new file mode 100644 index 000000000..973163f36 --- /dev/null +++ b/command/issue.go @@ -0,0 +1,114 @@ +package command + +import ( + "fmt" + "strconv" + + "github.com/github/gh-cli/api" + "github.com/github/gh-cli/utils" + "github.com/spf13/cobra" +) + +func init() { + 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: "status", + Short: "Display issue status", + RunE: issueList, + }, + &cobra.Command{ + Use: "view [issue-number]", + Args: cobra.MinimumNArgs(1), + Short: "Open a issue in the browser", + RunE: issueView, + }, + ) + + RootCmd.AddCommand(issueCmd) +} + +func issueList(cmd *cobra.Command, args []string) error { + 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 { + 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..2181fcf37 --- /dev/null +++ b/command/issue_test.go @@ -0,0 +1,61 @@ +package command + +import ( + "os" + "regexp" + "testing" + + "github.com/github/gh-cli/test" +) + +func TestIssueStatus(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + jsonFile, _ := os.Open("../test/fixtures/issueStatus.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := test.RunCommand(RootCmd, "issue status") + if err != nil { + t.Errorf("error running command `issue status`: %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/issueStatus.json b/test/fixtures/issueStatus.json new file mode 100644 index 000000000..784c0cc8f --- /dev/null +++ b/test/fixtures/issueStatus.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 }