diff --git a/api/queries.go b/api/queries.go index 727c18630..5daf10244 100644 --- a/api/queries.go +++ b/api/queries.go @@ -33,6 +33,7 @@ type IssuesPayload struct { type Issue struct { Number int Title string + URL string } func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { diff --git a/api/queries_issue.go b/api/queries_issue.go new file mode 100644 index 000000000..56a235c89 --- /dev/null +++ b/api/queries_issue.go @@ -0,0 +1,40 @@ +package api + +func IssueCreate(client *Client, ghRepo Repo, params map[string]interface{}) (*Issue, error) { + repoID, err := GitHubRepoId(client, ghRepo) + if err != nil { + return nil, err + } + + query := ` + mutation CreateIssue($input: CreateIssueInput!) { + createIssue(input: $input) { + issue { + url + } + } + }` + + inputParams := map[string]interface{}{ + "repositoryId": repoID, + } + for key, val := range params { + inputParams[key] = val + } + variables := map[string]interface{}{ + "input": inputParams, + } + + result := struct { + CreateIssue struct { + Issue Issue + } + }{} + + err = client.GraphQL(query, variables, &result) + if err != nil { + return nil, err + } + + return &result.CreateIssue.Issue, nil +} diff --git a/api/queries_repo.go b/api/queries_repo.go new file mode 100644 index 000000000..92010b880 --- /dev/null +++ b/api/queries_repo.go @@ -0,0 +1,31 @@ +package api + +import "fmt" + +func GitHubRepoId(client *Client, ghRepo Repo) (string, error) { + owner := ghRepo.RepoOwner() + repo := ghRepo.RepoName() + + query := ` + query FindRepoID($owner:String!, $name:String!) { + repository(owner:$owner, name:$name) { + id + } + }` + variables := map[string]interface{}{ + "owner": owner, + "name": repo, + } + + result := struct { + Repository struct { + Id string + } + }{} + err := client.GraphQL(query, variables, &result) + if err != nil || result.Repository.Id == "" { + return "", fmt.Errorf("failed to determine GH repo ID: %s", err) + } + + return result.Repository.Id, nil +} diff --git a/command/issue.go b/command/issue.go index da24b66f9..8d1fcb1d7 100644 --- a/command/issue.go +++ b/command/issue.go @@ -2,39 +2,46 @@ package command import ( "fmt" + "io/ioutil" + "os" "strconv" + "strings" "github.com/github/gh-cli/api" "github.com/github/gh-cli/utils" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" ) 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) - }, - } - + RootCmd.AddCommand(issueCmd) issueCmd.AddCommand( &cobra.Command{ Use: "status", - Short: "Display issue status", + Short: "Show status of relevant issues", RunE: issueList, }, &cobra.Command{ - Use: "view [issue-number]", + Use: "view ", Args: cobra.MinimumNArgs(1), - Short: "Open a issue in the browser", + Short: "Open an issue in the browser", RunE: issueView, }, ) + issueCmd.AddCommand(issueCreateCmd) + issueCreateCmd.Flags().StringArrayP("message", "m", nil, "set title and body") + issueCreateCmd.Flags().BoolP("web", "w", false, "open the web browser to create an issue") +} - RootCmd.AddCommand(issueCmd) +var issueCmd = &cobra.Command{ + Use: "issue", + Short: "Work with GitHub issues", + Long: `Helps you work with issues.`, +} +var issueCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new issue", + RunE: issueCreate, } func issueList(cmd *cobra.Command, args []string) error { @@ -107,6 +114,77 @@ func issueView(cmd *cobra.Command, args []string) error { return utils.OpenInBrowser(openURL) } +func issueCreate(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + + baseRepo, err := ctx.BaseRepo() + if err != nil { + return err + } + + if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { + // TODO: move URL generation into GitHubRepository + openURL := fmt.Sprintf("https://github.com/%s/%s/issues/new", baseRepo.RepoOwner(), baseRepo.RepoName()) + // TODO: figure out how to stub this in tests + if stat, err := os.Stat(".github/ISSUE_TEMPLATE"); err == nil && stat.IsDir() { + openURL += "/choose" + } + return utils.OpenInBrowser(openURL) + } + + var title string + var body string + + message, err := cmd.Flags().GetStringArray("message") + if err != nil { + return err + } + + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + if len(message) > 0 { + title = message[0] + body = strings.Join(message[1:], "\n\n") + } else { + // TODO: open the text editor for issue title & body + input := os.Stdin + if terminal.IsTerminal(int(input.Fd())) { + cmd.Println("Enter the issue title and body; press Enter + Ctrl-D when done:") + } + inputBytes, err := ioutil.ReadAll(input) + if err != nil { + return err + } + + parts := strings.SplitN(string(inputBytes), "\n\n", 2) + if len(parts) > 0 { + title = parts[0] + } + if len(parts) > 1 { + body = parts[1] + } + } + + if title == "" { + return fmt.Errorf("aborting due to empty title") + } + params := map[string]interface{}{ + "title": title, + "body": body, + } + + newIssue, err := api.IssueCreate(apiClient, baseRepo, params) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL) + return nil +} + func printIssues(issues ...api.Issue) { for _, issue := range issues { fmt.Printf(" #%d %s\n", issue.Number, truncate(70, issue.Title)) diff --git a/command/issue_test.go b/command/issue_test.go index 4571bc43e..29d494663 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -1,6 +1,9 @@ package command import ( + "bytes" + "encoding/json" + "io/ioutil" "os" "os/exec" "regexp" @@ -69,3 +72,46 @@ func TestIssueView(t *testing.T) { t.Errorf("got: %q", url) } } + +func TestIssueCreate(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID" + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `)) + + out := bytes.Buffer{} + issueCreateCmd.SetOut(&out) + + RootCmd.SetArgs([]string{"issue", "create", "-m", "hello", "-m", "ab", "-m", "cd"}) + _, err := RootCmd.ExecuteC() + 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, "ab\n\ncd") + + eq(t, out.String(), "https://github.com/OWNER/REPO/issues/12\n") +} diff --git a/go.sum b/go.sum index 29b609b5c..af7d46f56 100644 --- a/go.sum +++ b/go.sum @@ -5,7 +5,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -37,10 +36,7 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6 github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= 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 } + } +}