Skip to content

Commit 8ef2bb4

Browse files
Yuki Osakisamcoe
authored andcommitted
Comment on issues from editor
1 parent 86eb264 commit 8ef2bb4

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed

api/queries_issue.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,25 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
123123
return &result.CreateIssue.Issue, nil
124124
}
125125

126+
func CommentCreate(client *Client, repoHost string, params map[string]interface{}) error {
127+
query := `
128+
mutation CommentCreate($input: AddCommentInput!) {
129+
addComment(input: $input) { clientMutationId }
130+
}`
131+
132+
variables := map[string]interface{}{
133+
"input": params,
134+
}
135+
136+
err := client.GraphQL(repoHost, query, variables, nil)
137+
138+
if err != nil {
139+
return err
140+
}
141+
142+
return nil
143+
}
144+
126145
func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) {
127146
type response struct {
128147
Repository struct {

pkg/cmd/issue/comment/comment.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package comment
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/AlecAivazis/survey/v2"
8+
"github.com/MakeNowJust/heredoc"
9+
"github.com/cli/cli/api"
10+
"github.com/cli/cli/internal/config"
11+
"github.com/cli/cli/internal/ghrepo"
12+
"github.com/cli/cli/pkg/cmd/issue/shared"
13+
"github.com/cli/cli/pkg/cmdutil"
14+
"github.com/cli/cli/pkg/iostreams"
15+
"github.com/cli/cli/pkg/prompt"
16+
"github.com/cli/cli/pkg/surveyext"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
type CommentOptions struct {
21+
HttpClient func() (*http.Client, error)
22+
Config func() (config.Config, error)
23+
IO *iostreams.IOStreams
24+
BaseRepo func() (ghrepo.Interface, error)
25+
Body string
26+
SelectorArg string
27+
Interactive bool
28+
Action Action
29+
}
30+
31+
func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command {
32+
opts := &CommentOptions{
33+
IO: f.IOStreams,
34+
HttpClient: f.HttpClient,
35+
Config: f.Config,
36+
}
37+
38+
cmd := &cobra.Command{
39+
Use: "comment {<number> | <url>}",
40+
Short: "Create comments for the issue",
41+
Example: heredoc.Doc(`
42+
$ gh issue comment --body "I found a bug. Nothing works"
43+
`),
44+
Args: cobra.ExactArgs(1),
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
// support `-R, --repo` override
47+
opts.BaseRepo = f.BaseRepo
48+
49+
bodyProvided := cmd.Flags().Changed("body")
50+
51+
opts.Interactive = !(bodyProvided)
52+
53+
if len(args) > 0 {
54+
opts.SelectorArg = args[0]
55+
}
56+
57+
if runF != nil {
58+
return runF(opts)
59+
}
60+
return commentRun(opts)
61+
},
62+
}
63+
64+
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body.")
65+
66+
return cmd
67+
}
68+
69+
type Action int
70+
71+
const (
72+
SubmitAction Action = iota
73+
CancelAction
74+
)
75+
76+
func titleBodySurvey(editorCommand string, issueState *CommentOptions, apiClient *api.Client, repo ghrepo.Interface, providedBody string) error {
77+
bodyQuestion := &survey.Question{
78+
Name: "body",
79+
Prompt: &surveyext.GhEditor{
80+
BlankAllowed: false,
81+
EditorCommand: editorCommand,
82+
Editor: &survey.Editor{
83+
Message: "Body",
84+
FileName: "*.md",
85+
Default: issueState.Body,
86+
},
87+
},
88+
}
89+
90+
var qs []*survey.Question
91+
92+
if providedBody == "" {
93+
qs = append(qs, bodyQuestion)
94+
}
95+
96+
err := prompt.SurveyAsk(qs, issueState)
97+
if err != nil {
98+
panic(fmt.Sprintf("could not prompt: %w", err))
99+
}
100+
101+
confirmA, err := confirmSubmission()
102+
103+
if err != nil {
104+
panic(fmt.Sprintf("unable to confirm: %w", err))
105+
}
106+
107+
issueState.Action = confirmA
108+
return nil
109+
}
110+
111+
func confirmSubmission() (Action, error) {
112+
const (
113+
submitLabel = "Submit"
114+
cancelLabel = "Cancel"
115+
)
116+
117+
options := []string{submitLabel, cancelLabel}
118+
119+
confirmAnswers := struct {
120+
Confirmation int
121+
}{}
122+
confirmQs := []*survey.Question{
123+
{
124+
Name: "confirmation",
125+
Prompt: &survey.Select{
126+
Message: "What's next?",
127+
Options: options,
128+
},
129+
},
130+
}
131+
132+
err := prompt.SurveyAsk(confirmQs, &confirmAnswers)
133+
if err != nil {
134+
return -1, fmt.Errorf("could not prompt: %w", err)
135+
}
136+
137+
switch options[confirmAnswers.Confirmation] {
138+
case submitLabel:
139+
return SubmitAction, nil
140+
case cancelLabel:
141+
return CancelAction, nil
142+
default:
143+
return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
144+
}
145+
}
146+
147+
func commentRun(opts *CommentOptions) error {
148+
httpClient, err := opts.HttpClient()
149+
if err != nil {
150+
return err
151+
}
152+
apiClient := api.NewClientFromHTTP(httpClient)
153+
154+
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
155+
156+
if err != nil {
157+
return err
158+
}
159+
160+
isTerminal := opts.IO.IsStdoutTTY()
161+
162+
if isTerminal {
163+
fmt.Fprintf(opts.IO.ErrOut, "\nMake a comment for %s in %s\n\n", issue.Title, ghrepo.FullName(baseRepo))
164+
}
165+
166+
action := SubmitAction
167+
body := opts.Body
168+
169+
if opts.Interactive {
170+
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
171+
if err != nil {
172+
return err
173+
}
174+
175+
err = titleBodySurvey(editorCommand, opts, apiClient, baseRepo, body)
176+
177+
if err != nil {
178+
return err
179+
}
180+
181+
action = opts.Action
182+
}
183+
184+
if action == CancelAction {
185+
return nil
186+
} else if action == SubmitAction {
187+
params := map[string]interface{}{
188+
"subjectId": issue.ID,
189+
"body": opts.Body,
190+
}
191+
192+
err = api.CommentCreate(apiClient, baseRepo.RepoHost(), params)
193+
if err != nil {
194+
return err
195+
}
196+
197+
fmt.Fprintln(opts.IO.Out, issue.URL)
198+
199+
return nil
200+
}
201+
return fmt.Errorf("unexpected action state: %v", action)
202+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package comment
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io/ioutil"
7+
"net/http"
8+
"reflect"
9+
"regexp"
10+
"testing"
11+
12+
"github.com/cli/cli/internal/config"
13+
"github.com/cli/cli/internal/ghrepo"
14+
"github.com/cli/cli/pkg/cmdutil"
15+
"github.com/cli/cli/pkg/httpmock"
16+
"github.com/cli/cli/pkg/iostreams"
17+
"github.com/cli/cli/test"
18+
"github.com/google/shlex"
19+
)
20+
21+
func eq(t *testing.T, got interface{}, expected interface{}) {
22+
t.Helper()
23+
if !reflect.DeepEqual(got, expected) {
24+
t.Errorf("expected: %v, got: %v", expected, got)
25+
}
26+
}
27+
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
28+
io, _, stdout, stderr := iostreams.Test()
29+
io.SetStdoutTTY(isTTY)
30+
io.SetStdinTTY(isTTY)
31+
io.SetStderrTTY(isTTY)
32+
33+
factory := &cmdutil.Factory{
34+
IOStreams: io,
35+
HttpClient: func() (*http.Client, error) {
36+
return &http.Client{Transport: rt}, nil
37+
},
38+
Config: func() (config.Config, error) {
39+
return config.NewBlankConfig(), nil
40+
},
41+
BaseRepo: func() (ghrepo.Interface, error) {
42+
return ghrepo.New("OWNER", "REPO"), nil
43+
},
44+
}
45+
46+
cmd := NewCmdComment(factory, func(opts *CommentOptions) error {
47+
return commentRun(opts)
48+
})
49+
argv, err := shlex.Split(cli)
50+
if err != nil {
51+
return nil, err
52+
}
53+
cmd.SetArgs(argv)
54+
55+
cmd.SetIn(&bytes.Buffer{})
56+
cmd.SetOut(ioutil.Discard)
57+
cmd.SetErr(ioutil.Discard)
58+
59+
_, err = cmd.ExecuteC()
60+
return &test.CmdOut{
61+
OutBuf: stdout,
62+
ErrBuf: stderr,
63+
}, err
64+
}
65+
66+
func TestCommentCreate(t *testing.T) {
67+
http := &httpmock.Registry{}
68+
defer http.Verify(t)
69+
70+
http.StubResponse(200, bytes.NewBufferString(`
71+
{ "data": { "repository": {
72+
"hasIssuesEnabled": true,
73+
"issue": { "number": 96, "title": "The title of the issue"}
74+
} } }
75+
`))
76+
77+
http.StubResponse(200, bytes.NewBufferString(`{"mutationId": "THE-ID"}`))
78+
79+
output, err := runCommand(http, true, `13 -b "cash rules everything around me"`)
80+
if err != nil {
81+
t.Errorf("error running command `issue comment`: %v", err)
82+
}
83+
84+
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
85+
reqBody := struct {
86+
Variables struct {
87+
Input struct {
88+
Body string
89+
}
90+
}
91+
}{}
92+
_ = json.Unmarshal(bodyBytes, &reqBody)
93+
94+
eq(t, reqBody.Variables.Input.Body, "cash rules everything around me")
95+
96+
r := regexp.MustCompile(`Make a comment for The title of the issue`)
97+
98+
if !r.MatchString(output.Stderr()) {
99+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
100+
}
101+
}
102+
103+
func TestIssue_Disabled(t *testing.T) {
104+
http := &httpmock.Registry{}
105+
defer http.Verify(t)
106+
107+
http.StubResponse(200, bytes.NewBufferString(`
108+
{ "data": { "repository": {
109+
"hasIssuesEnabled": false
110+
} } }
111+
`))
112+
113+
_, err := runCommand(http, true, "13")
114+
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
115+
t.Fatalf("got error: %v", err)
116+
}
117+
}

pkg/cmd/issue/issue.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package issue
33
import (
44
"github.com/MakeNowJust/heredoc"
55
cmdClose "github.com/cli/cli/pkg/cmd/issue/close"
6+
cmdComment "github.com/cli/cli/pkg/cmd/issue/comment"
67
cmdCreate "github.com/cli/cli/pkg/cmd/issue/create"
78
cmdList "github.com/cli/cli/pkg/cmd/issue/list"
89
cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen"
@@ -40,6 +41,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
4041
cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil))
4142
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
4243
cmd.AddCommand(cmdView.NewCmdView(f, nil))
44+
cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
4345

4446
return cmd
4547
}

0 commit comments

Comments
 (0)